@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
text/typescript
/**
* @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");
});
});
});