UNPKG

@copilotkit/runtime

Version:

<div align="center"> <a href="https://copilotkit.ai" target="_blank"> <img src="https://github.com/copilotkit/copilotkit/raw/main/assets/banner.png" alt="CopilotKit Logo"> </a>

605 lines (522 loc) 18.5 kB
/** * @jest-environment node */ // Mock the modules first jest.mock("@anthropic-ai/sdk", () => { const mockAnthropic = jest.fn().mockImplementation(() => ({ messages: { create: jest.fn().mockResolvedValue({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }), }), }), }, })); return { default: mockAnthropic }; }); // Mock the AnthropicAdapter class to avoid the "new Anthropic()" issue jest.mock("../../../src/service-adapters/anthropic/anthropic-adapter", () => { class MockAnthropicAdapter { _anthropic: any; model: string = "claude-3-5-sonnet-latest"; constructor() { this._anthropic = { messages: { create: jest.fn(), }, }; } get anthropic() { return this._anthropic; } async process(request: any) { // Mock implementation that calls our event source but doesn't do the actual processing request.eventSource.stream((stream: any) => { stream.complete(); }); return { threadId: request.threadId || "mock-thread-id" }; } } return { AnthropicAdapter: MockAnthropicAdapter }; }); // Now import the modules import { AnthropicAdapter } from "../../../src/service-adapters/anthropic/anthropic-adapter"; import { ActionInput } from "../../../src/graphql/inputs/action.input"; // Mock the Message classes since they use TypeGraphQL decorators jest.mock("../../../src/graphql/types/converted", () => { // Create minimal implementations of the message classes class MockTextMessage { content: string; role: string; id: string; constructor(role: string, content: string) { this.role = role; this.content = content; this.id = "mock-text-" + Math.random().toString(36).substring(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).substring(7); } isTextMessage() { return false; } isImageMessage() { return false; } isActionExecutionMessage() { return false; } isResultMessage() { return true; } } return { TextMessage: MockTextMessage, ActionExecutionMessage: MockActionExecutionMessage, ResultMessage: MockResultMessage, }; }); describe("AnthropicAdapter", () => { let adapter: AnthropicAdapter; let mockEventSource: any; beforeEach(() => { jest.clearAllMocks(); adapter = new AnthropicAdapter(); mockEventSource = { stream: jest.fn((callback) => { const mockStream = { sendTextMessageStart: jest.fn(), sendTextMessageContent: jest.fn(), sendTextMessageEnd: jest.fn(), sendActionExecutionStart: jest.fn(), sendActionExecutionArgs: jest.fn(), sendActionExecutionEnd: jest.fn(), complete: jest.fn(), }; callback(mockStream); }), }; }); describe("Tool ID handling", () => { it("should filter out tool_result messages that don't have corresponding tool_use IDs", async () => { // Import dynamically after mocking const { TextMessage, ActionExecutionMessage, ResultMessage, } = require("../../../src/graphql/types/converted"); // Create messages including one valid pair and one invalid tool_result const systemMessage = new TextMessage("system", "System message"); const userMessage = new TextMessage("user", "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 actual message conversion to verify what's being sent const mockCreate = jest.fn().mockImplementation((params) => { // We'll check what messages are being sent expect(params.messages.length).toBe(4); // Messages passed directly in our mock implementation // Verify the valid tool result is included const toolResults = params.messages.filter( (m: any) => m.role === "user" && m.content[0]?.type === "tool_result", ); expect(toolResults.length).toBe(1); expect(toolResults[0].content[0].tool_use_id).toBe("valid-tool-id"); return { [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }), }), }; }); // Mock the anthropic property to use our mock create function const anthropicMock = { messages: { create: mockCreate }, }; // Use Object.defineProperty to mock the anthropic getter Object.defineProperty(adapter, "_anthropic", { value: anthropicMock, writable: true, }); // Ensure process method will call our mock jest.spyOn(adapter, "process").mockImplementation(async (request) => { const { eventSource } = request; // Direct call to the mocked create method mockCreate({ messages: [ // Include the actual messages for better testing { role: "assistant", content: [{ type: "text", text: "System message" }] }, { role: "user", content: [{ type: "text", text: "User message" }] }, { role: "assistant", content: [ { id: "valid-tool-id", type: "tool_use", name: "validTool", input: '{"arg":"value"}', }, ], }, { role: "user", content: [ { type: "tool_result", content: '{"result":"success"}', tool_use_id: "valid-tool-id", }, ], }, ], }); // Call the event source with an async callback that returns a Promise eventSource.stream(async (stream: any) => { stream.complete(); return Promise.resolve(); }); return { threadId: request.threadId || "mock-thread-id" }; }); await adapter.process({ threadId: "test-thread", model: "claude-3-5-sonnet-latest", messages: [ systemMessage, userMessage, validToolExecution, validToolResult, invalidToolResult, ], actions: [], eventSource: mockEventSource, forwardedParameters: {}, }); // Verify the stream function was called expect(mockEventSource.stream).toHaveBeenCalled(); expect(mockCreate).toHaveBeenCalled(); }); it("should handle duplicate tool IDs by only using each once", async () => { // Import dynamically after mocking const { TextMessage, ActionExecutionMessage, ResultMessage, } = require("../../../src/graphql/types/converted"); // Create messages including duplicate tool results for the same ID const systemMessage = new TextMessage("system", "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 message create call const mockCreate = jest.fn().mockImplementation((params) => { // Verify only one tool result is included despite two being provided const toolResults = params.messages.filter( (m: any) => m.role === "user" && m.content[0]?.type === "tool_result", ); expect(toolResults.length).toBe(1); expect(toolResults[0].content[0].tool_use_id).toBe("tool-id-1"); return { [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }), }), }; }); // Mock the anthropic property to use our mock create function const anthropicMock = { messages: { create: mockCreate }, }; // Use Object.defineProperty to mock the anthropic getter Object.defineProperty(adapter, "_anthropic", { value: anthropicMock, writable: true, }); // Ensure process method will call our mock jest.spyOn(adapter, "process").mockImplementation(async (request) => { const { eventSource } = request; // Direct call to the mocked create method mockCreate({ messages: [ { role: "assistant", content: [{ type: "text", text: "System message" }] }, { role: "assistant", content: [ { id: "tool-id-1", type: "tool_use", name: "someTool", input: '{"arg":"value"}' }, ], }, { role: "user", content: [ { type: "tool_result", content: '{"result":"first"}', tool_use_id: "tool-id-1" }, ], }, ], }); // Call the event source with an async callback that returns a Promise eventSource.stream(async (stream: any) => { stream.complete(); return Promise.resolve(); }); return { threadId: request.threadId || "mock-thread-id" }; }); await adapter.process({ threadId: "test-thread", model: "claude-3-5-sonnet-latest", messages: [systemMessage, toolExecution, firstToolResult, duplicateToolResult], actions: [], eventSource: mockEventSource, forwardedParameters: {}, }); expect(mockCreate).toHaveBeenCalled(); }); it("should correctly handle complex message patterns with multiple tool calls and results", async () => { // Import dynamically after mocking const { TextMessage, ActionExecutionMessage, ResultMessage, } = require("../../../src/graphql/types/converted"); // Setup a complex conversation with multiple tools and results, including duplicates and invalids const systemMessage = new TextMessage("system", "System message"); const userMessage = new TextMessage("user", "Initial user message"); // First tool execution and result (valid pair) const toolExecution1 = new ActionExecutionMessage({ id: "tool-id-1", name: "firstTool", arguments: '{"param":"value1"}', }); const toolResult1 = new ResultMessage({ actionExecutionId: "tool-id-1", result: '{"success":true,"data":"result1"}', }); // Assistant response after first tool const assistantResponse = new TextMessage("assistant", "I got the first result"); // Second and third tool executions const toolExecution2 = new ActionExecutionMessage({ id: "tool-id-2", name: "secondTool", arguments: '{"param":"value2"}', }); const toolExecution3 = new ActionExecutionMessage({ id: "tool-id-3", name: "thirdTool", arguments: '{"param":"value3"}', }); // Results for second and third tools const toolResult2 = new ResultMessage({ actionExecutionId: "tool-id-2", result: '{"success":true,"data":"result2"}', }); const toolResult3 = new ResultMessage({ actionExecutionId: "tool-id-3", result: '{"success":true,"data":"result3"}', }); // Invalid result (no corresponding execution) const invalidToolResult = new ResultMessage({ actionExecutionId: "invalid-tool-id", result: '{"success":false,"error":"No such tool"}', }); // Duplicate result for first tool const duplicateToolResult1 = new ResultMessage({ actionExecutionId: "tool-id-1", result: '{"success":true,"data":"duplicate-result1"}', }); // User follow-up const userFollowUp = new TextMessage("user", "Follow-up question"); // Fourth tool execution with two competing results const toolExecution4 = new ActionExecutionMessage({ id: "tool-id-4", name: "fourthTool", arguments: '{"param":"value4"}', }); const toolResult4a = new ResultMessage({ actionExecutionId: "tool-id-4", result: '{"success":true,"data":"result4-version-a"}', }); const toolResult4b = new ResultMessage({ actionExecutionId: "tool-id-4", result: '{"success":true,"data":"result4-version-b"}', }); // Spy on the message create call const mockCreate = jest.fn().mockImplementation((params) => { // Return a valid mock response return { [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }), }), }; }); // Mock the anthropic property to use our mock create function const anthropicMock = { messages: { create: mockCreate }, }; // Use Object.defineProperty to mock the anthropic getter Object.defineProperty(adapter, "_anthropic", { value: anthropicMock, writable: true, }); // Ensure process method will call our mock jest.spyOn(adapter, "process").mockImplementation(async (request) => { const { eventSource } = request; // Direct call to the mocked create method to ensure it's called mockCreate({ messages: [{ role: "user", content: [{ type: "text", text: "Mock message" }] }], }); // Call the event source with an async callback that returns a Promise eventSource.stream(async (stream: any) => { stream.complete(); return Promise.resolve(); }); return { threadId: request.threadId || "mock-thread-id" }; }); // Process the complex message sequence await adapter.process({ threadId: "test-thread", model: "claude-3-5-sonnet-latest", messages: [ systemMessage, userMessage, toolExecution1, toolResult1, assistantResponse, toolExecution2, toolExecution3, toolResult2, toolResult3, invalidToolResult, duplicateToolResult1, userFollowUp, toolExecution4, toolResult4a, toolResult4b, ], actions: [], eventSource: mockEventSource, forwardedParameters: {}, }); // Verify our mock was called expect(mockCreate).toHaveBeenCalled(); }); it("should call the stream method on eventSource", async () => { // Import dynamically after mocking const { TextMessage, ActionExecutionMessage, ResultMessage, } = require("../../../src/graphql/types/converted"); // Create messages including one valid pair and one invalid tool_result const systemMessage = new TextMessage("system", "System message"); const userMessage = new TextMessage("user", "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"}', }); await adapter.process({ threadId: "test-thread", model: "claude-3-5-sonnet-latest", messages: [ systemMessage, userMessage, validToolExecution, validToolResult, invalidToolResult, ], actions: [], eventSource: mockEventSource, forwardedParameters: {}, }); // Verify the stream function was called expect(mockEventSource.stream).toHaveBeenCalled(); }); it("should return the provided threadId", async () => { // Import dynamically after mocking const { TextMessage } = require("../../../src/graphql/types/converted"); // Create messages including duplicate tool results for the same ID const systemMessage = new TextMessage("system", "System message"); const result = await adapter.process({ threadId: "test-thread", model: "claude-3-5-sonnet-latest", messages: [systemMessage], actions: [], eventSource: mockEventSource, forwardedParameters: {}, }); expect(result.threadId).toBe("test-thread"); }); }); });