first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
+178
@@ -0,0 +1,178 @@
|
||||
import type { EngineResponse } from 'n8n-workflow';
|
||||
|
||||
import * as agentExecution from '../buildSteps';
|
||||
|
||||
import type { RequestResponseMetadata } from '../types';
|
||||
import { buildResponseMetadata } from '../buildResponseMetadata';
|
||||
|
||||
// Mock the buildSteps function from agent-execution
|
||||
jest.mock('../buildSteps', () => ({
|
||||
buildSteps: jest.fn((response) => {
|
||||
// Mock implementation: return previous requests if they exist
|
||||
if (response?.actionResponses) {
|
||||
return response.actionResponses.map((ar: any) => ({
|
||||
action: {
|
||||
tool: ar.action.nodeName,
|
||||
toolInput: ar.action.input,
|
||||
log: 'mock log',
|
||||
toolCallId: ar.action.id,
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: JSON.stringify(ar.data),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('buildIterationMetadata', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return metadata with iterationCount 1 when response is undefined', () => {
|
||||
const result = buildResponseMetadata(undefined, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
previousRequests: [],
|
||||
itemIndex: 0,
|
||||
iterationCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return metadata with iterationCount 1 when response has no metadata', () => {
|
||||
const response = {
|
||||
actionResponses: [],
|
||||
} as unknown as EngineResponse<RequestResponseMetadata>;
|
||||
|
||||
const result = buildResponseMetadata(response, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
previousRequests: [],
|
||||
itemIndex: 0,
|
||||
iterationCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return metadata with iterationCount 1 when response metadata has no iterationCount', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = buildResponseMetadata(response, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
previousRequests: [],
|
||||
itemIndex: 0,
|
||||
iterationCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should increment iterationCount when response has existing iterationCount', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: {
|
||||
iterationCount: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildResponseMetadata(response, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
previousRequests: [],
|
||||
itemIndex: 0,
|
||||
iterationCount: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include previousRequests when response has actionResponses', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [
|
||||
{
|
||||
action: {
|
||||
id: 'call_123',
|
||||
nodeName: 'TestTool',
|
||||
input: { input: 'test data', id: 'call_123' },
|
||||
metadata: { itemIndex: 0 },
|
||||
actionType: 'ExecutionNodeAction',
|
||||
type: 'ai_tool',
|
||||
},
|
||||
data: {
|
||||
data: { ai_tool: [[{ json: { result: 'tool result' } }]] },
|
||||
executionTime: 0,
|
||||
startTime: 0,
|
||||
executionIndex: 0,
|
||||
source: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
iterationCount: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildResponseMetadata(response, 0);
|
||||
|
||||
expect(result.itemIndex).toBe(0);
|
||||
expect(result.iterationCount).toBe(2);
|
||||
expect(result.previousRequests).toHaveLength(1);
|
||||
expect(result.previousRequests?.[0]).toMatchObject({
|
||||
action: {
|
||||
tool: 'TestTool',
|
||||
toolCallId: 'call_123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple iterations correctly', () => {
|
||||
// First iteration
|
||||
const result1 = buildResponseMetadata(undefined, 0);
|
||||
expect(result1.iterationCount).toBe(1);
|
||||
|
||||
// Second iteration
|
||||
const response2: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: { iterationCount: 1 },
|
||||
};
|
||||
const result2 = buildResponseMetadata(response2, 0);
|
||||
expect(result2.iterationCount).toBe(2);
|
||||
|
||||
// Third iteration
|
||||
const response3: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: { iterationCount: 2 },
|
||||
};
|
||||
const result3 = buildResponseMetadata(response3, 0);
|
||||
expect(result3.iterationCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should pass correct itemIndex to buildSteps', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: { iterationCount: 1 },
|
||||
};
|
||||
|
||||
buildResponseMetadata(response, 5);
|
||||
|
||||
expect(agentExecution.buildSteps).toHaveBeenCalledWith(response, 5);
|
||||
});
|
||||
|
||||
it('should handle iterationCount starting from 0', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: {
|
||||
iterationCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildResponseMetadata(response, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
previousRequests: [],
|
||||
itemIndex: 0,
|
||||
iterationCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+1000
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,654 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import {
|
||||
HumanMessage,
|
||||
AIMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
trimMessages,
|
||||
} from '@langchain/core/messages';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { BaseChatMemory } from '@langchain/classic/memory';
|
||||
|
||||
import {
|
||||
loadMemory,
|
||||
saveToMemory,
|
||||
buildToolContext,
|
||||
extractToolCallId,
|
||||
buildMessagesFromSteps,
|
||||
} from '../memoryManagement';
|
||||
import type { ToolCallData } from '../types';
|
||||
|
||||
jest.mock('@langchain/core/messages', () => ({
|
||||
...jest.requireActual('@langchain/core/messages'),
|
||||
trimMessages: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('memoryManagement', () => {
|
||||
let mockMemory: jest.Mocked<BaseChatMemory>;
|
||||
let mockModel: jest.Mocked<BaseChatModel>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockMemory = mock<BaseChatMemory>();
|
||||
mockModel = mock<BaseChatModel>();
|
||||
});
|
||||
|
||||
describe('loadMemory', () => {
|
||||
it('should return undefined when no memory is provided', async () => {
|
||||
const result = await loadMemory(undefined);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load chat history from memory', async () => {
|
||||
const chatHistory = [new HumanMessage('Hello'), new AIMessage('Hi there!')];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toEqual(chatHistory);
|
||||
expect(mockMemory.loadMemoryVariables).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('should return empty array when chat_history is not present', async () => {
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({});
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should remove orphaned ToolMessage at start of chat history', async () => {
|
||||
// Simulates memory trimming that removed the AIMessage but left the ToolMessage
|
||||
const chatHistory = [
|
||||
new ToolMessage({ content: 'Result', tool_call_id: 'orphaned-id', name: 'tool' }),
|
||||
new HumanMessage('Next question'),
|
||||
new AIMessage('Answer'),
|
||||
];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(result?.[1]).toBeInstanceOf(AIMessage);
|
||||
});
|
||||
|
||||
it('should remove orphaned AIMessage with tool_calls at start', async () => {
|
||||
// Simulates memory trimming that kept AIMessage with tool_calls but removed the ToolMessage
|
||||
const orphanedAI = new AIMessage({
|
||||
content: 'Calling tool',
|
||||
tool_calls: [{ id: 'call-123', name: 'tool', args: {}, type: 'tool_call' }],
|
||||
});
|
||||
const chatHistory = [orphanedAI, new HumanMessage('Next question'), new AIMessage('Answer')];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(result?.[1]).toBeInstanceOf(AIMessage);
|
||||
});
|
||||
|
||||
it('should remove multiple consecutive orphaned ToolMessages at start', async () => {
|
||||
const chatHistory = [
|
||||
new ToolMessage({ content: 'Result 1', tool_call_id: 'id-1', name: 'tool1' }),
|
||||
new ToolMessage({ content: 'Result 2', tool_call_id: 'id-2', name: 'tool2' }),
|
||||
new ToolMessage({ content: 'Result 3', tool_call_id: 'id-3', name: 'tool3' }),
|
||||
new HumanMessage('Next question'),
|
||||
new AIMessage('Answer'),
|
||||
];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(result?.[1]).toBeInstanceOf(AIMessage);
|
||||
});
|
||||
|
||||
it('should remove chain of ToolMessage -> AIMessage(tool_calls) at start via recursive cleanup', async () => {
|
||||
// After removing the first ToolMessage, an orphaned AIMessage with tool_calls is revealed
|
||||
// (not followed by a ToolMessage), requiring another cleanup pass
|
||||
const chatHistory = [
|
||||
new ToolMessage({ content: 'Orphan result', tool_call_id: 'id-1', name: 'tool1' }),
|
||||
new AIMessage({
|
||||
content: 'Calling another tool',
|
||||
tool_calls: [{ id: 'call-2', name: 'tool2', args: {}, type: 'tool_call' as const }],
|
||||
}),
|
||||
new HumanMessage('Question'),
|
||||
new AIMessage('Answer'),
|
||||
];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(result?.[1]).toBeInstanceOf(AIMessage);
|
||||
});
|
||||
|
||||
it('should handle orphaned AIMessage(tool_calls) followed by more orphaned ToolMessages', async () => {
|
||||
const chatHistory = [
|
||||
new AIMessage({
|
||||
content: 'Calling tool',
|
||||
tool_calls: [{ id: 'call-1', name: 'tool1', args: {}, type: 'tool_call' as const }],
|
||||
}),
|
||||
// This AIMessage has tool_calls but no following ToolMessage (next is HumanMessage)
|
||||
new AIMessage({
|
||||
content: 'Another call',
|
||||
tool_calls: [{ id: 'call-2', name: 'tool2', args: {}, type: 'tool_call' as const }],
|
||||
}),
|
||||
new HumanMessage('Question'),
|
||||
new AIMessage('Answer'),
|
||||
];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(result?.[1]).toBeInstanceOf(AIMessage);
|
||||
});
|
||||
|
||||
it('should return empty array when all messages are orphans', async () => {
|
||||
const chatHistory = [
|
||||
new ToolMessage({ content: 'Result', tool_call_id: 'id-1', name: 'tool' }),
|
||||
new AIMessage({
|
||||
content: 'Call',
|
||||
tool_calls: [{ id: 'call-1', name: 'tool', args: {}, type: 'tool_call' as const }],
|
||||
}),
|
||||
];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should trim messages when maxTokens is provided', async () => {
|
||||
const chatHistory = [
|
||||
new SystemMessage('System prompt'),
|
||||
new HumanMessage('Hello'),
|
||||
new AIMessage('Hi there!'),
|
||||
new HumanMessage('How are you?'),
|
||||
new AIMessage('I am doing well!'),
|
||||
];
|
||||
const trimmedHistory = [
|
||||
new SystemMessage('System prompt'),
|
||||
new HumanMessage('How are you?'),
|
||||
new AIMessage('I am doing well!'),
|
||||
];
|
||||
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
(trimMessages as jest.Mock).mockResolvedValue(trimmedHistory);
|
||||
|
||||
const result = await loadMemory(mockMemory, mockModel, 2000);
|
||||
|
||||
expect(result).toEqual(trimmedHistory);
|
||||
expect(trimMessages).toHaveBeenCalledWith(chatHistory, {
|
||||
strategy: 'last',
|
||||
maxTokens: 2000,
|
||||
tokenCounter: mockModel,
|
||||
includeSystem: true,
|
||||
startOn: 'human',
|
||||
allowPartial: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not trim messages when maxTokens is not provided', async () => {
|
||||
const chatHistory = [new HumanMessage('Hello'), new AIMessage('Hi there!')];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory, mockModel);
|
||||
|
||||
expect(result).toEqual(chatHistory);
|
||||
expect(trimMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not trim messages when model is not provided', async () => {
|
||||
const chatHistory = [new HumanMessage('Hello'), new AIMessage('Hi there!')];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory, undefined, 2000);
|
||||
|
||||
expect(result).toEqual(chatHistory);
|
||||
expect(trimMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveToMemory', () => {
|
||||
it('should save conversation to memory', async () => {
|
||||
const input = 'What is 2+2?';
|
||||
const output = 'The answer is 4';
|
||||
|
||||
await saveToMemory(input, output, mockMemory);
|
||||
|
||||
expect(mockMemory.saveContext).toHaveBeenCalledWith({ input }, { output });
|
||||
});
|
||||
|
||||
it('should not save when output is empty', async () => {
|
||||
const input = 'What is 2+2?';
|
||||
const output = '';
|
||||
|
||||
await saveToMemory(input, output, mockMemory);
|
||||
|
||||
expect(mockMemory.saveContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not save when memory is not provided', async () => {
|
||||
const input = 'What is 2+2?';
|
||||
const output = 'The answer is 4';
|
||||
|
||||
await saveToMemory(input, output, undefined);
|
||||
|
||||
// Should not throw error
|
||||
expect(mockMemory.saveContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not save when both output and memory are missing', async () => {
|
||||
const input = 'What is 2+2?';
|
||||
|
||||
await saveToMemory(input, '', undefined);
|
||||
|
||||
expect(mockMemory.saveContext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractToolCallId', () => {
|
||||
beforeEach(() => {
|
||||
// Mock Date.now() to return consistent values for synthetic IDs
|
||||
jest.spyOn(Date, 'now').mockReturnValue(1234567890);
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should extract string ID directly', () => {
|
||||
const result = extractToolCallId('call-123', 'calculator');
|
||||
expect(result).toBe('call-123');
|
||||
});
|
||||
|
||||
it('should extract ID from object with id property', () => {
|
||||
const result = extractToolCallId({ id: 'call-456' }, 'search');
|
||||
expect(result).toBe('call-456');
|
||||
});
|
||||
|
||||
it('should extract ID from array', () => {
|
||||
const result = extractToolCallId(['call-789'], 'weather');
|
||||
expect(result).toBe('call-789');
|
||||
});
|
||||
|
||||
it('should recursively extract from nested array', () => {
|
||||
const result = extractToolCallId([['call-nested']], 'tool');
|
||||
expect(result).toBe('call-nested');
|
||||
});
|
||||
|
||||
it('should extract from array of objects', () => {
|
||||
const result = extractToolCallId([{ id: 'call-array-obj' }], 'tool');
|
||||
expect(result).toBe('call-array-obj');
|
||||
});
|
||||
|
||||
it('should generate synthetic ID for null', () => {
|
||||
const result = extractToolCallId(null, 'unknown');
|
||||
expect(result).toBe('synthetic_unknown_1234567890');
|
||||
});
|
||||
|
||||
it('should generate synthetic ID for undefined', () => {
|
||||
const result = extractToolCallId(undefined, 'test');
|
||||
expect(result).toBe('synthetic_test_1234567890');
|
||||
});
|
||||
|
||||
it('should generate synthetic ID for empty string', () => {
|
||||
const result = extractToolCallId('', 'tool');
|
||||
expect(result).toBe('synthetic_tool_1234567890');
|
||||
});
|
||||
|
||||
it('should generate synthetic ID for object without id property', () => {
|
||||
const result = extractToolCallId({ other: 'value' }, 'tool');
|
||||
expect(result).toBe('synthetic_tool_1234567890');
|
||||
});
|
||||
|
||||
it('should generate synthetic ID for empty array', () => {
|
||||
const result = extractToolCallId([], 'tool');
|
||||
expect(result).toBe('synthetic_tool_1234567890');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMessagesFromSteps', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should build messages with proper AIMessage from messageLog', () => {
|
||||
const aiMessage = new AIMessage({
|
||||
content: 'Let me calculate that',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call-123',
|
||||
name: 'calculator',
|
||||
args: { expression: '2+2' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'calculator',
|
||||
toolInput: { expression: '2+2' },
|
||||
log: 'Using calculator',
|
||||
messageLog: [aiMessage],
|
||||
toolCallId: 'call-123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '4',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildMessagesFromSteps(steps);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe(aiMessage);
|
||||
expect(result[1]).toBeInstanceOf(ToolMessage);
|
||||
expect(result[1].content).toBe('4');
|
||||
expect((result[1] as ToolMessage).tool_call_id).toBe('call-123');
|
||||
expect((result[1] as ToolMessage).name).toBe('calculator');
|
||||
});
|
||||
|
||||
it('should create synthetic AIMessage when messageLog is missing', () => {
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'search',
|
||||
toolInput: { query: 'test' },
|
||||
log: 'Searching',
|
||||
toolCallId: 'call-456',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: 'Found results',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildMessagesFromSteps(steps);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBeInstanceOf(AIMessage);
|
||||
expect(result[0].content).toContain('search');
|
||||
expect(result[0].content).toContain('test');
|
||||
expect((result[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((result[0] as AIMessage).tool_calls?.[0].id).toBe('call-456');
|
||||
});
|
||||
|
||||
it('should handle multiple tool calls in sequence', () => {
|
||||
const aiMessage1 = new AIMessage({
|
||||
content: 'Checking weather',
|
||||
tool_calls: [
|
||||
{ id: 'call-1', name: 'weather', args: { location: 'NYC' }, type: 'tool_call' },
|
||||
],
|
||||
});
|
||||
|
||||
const aiMessage2 = new AIMessage({
|
||||
content: 'Getting time',
|
||||
tool_calls: [{ id: 'call-2', name: 'time', args: { timezone: 'EST' }, type: 'tool_call' }],
|
||||
});
|
||||
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'weather',
|
||||
toolInput: { location: 'NYC' },
|
||||
log: 'Weather',
|
||||
messageLog: [aiMessage1],
|
||||
toolCallId: 'call-1',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: 'Sunny, 72°F',
|
||||
},
|
||||
{
|
||||
action: {
|
||||
tool: 'time',
|
||||
toolInput: { timezone: 'EST' },
|
||||
log: 'Time',
|
||||
messageLog: [aiMessage2],
|
||||
toolCallId: 'call-2',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '14:30',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildMessagesFromSteps(steps);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0]).toBe(aiMessage1);
|
||||
expect(result[1]).toBeInstanceOf(ToolMessage);
|
||||
expect(result[2]).toBe(aiMessage2);
|
||||
expect(result[3]).toBeInstanceOf(ToolMessage);
|
||||
});
|
||||
|
||||
it('should return empty array for empty steps', () => {
|
||||
const result = buildMessagesFromSteps([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveToMemory with steps (message-based storage)', () => {
|
||||
let mockChatHistory: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
mockChatHistory = {
|
||||
addMessages: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockMemory.chatHistory = mockChatHistory;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should use message-based storage when steps are provided and addMessages is available', async () => {
|
||||
const aiMessage = new AIMessage({
|
||||
content: 'Let me calculate',
|
||||
tool_calls: [
|
||||
{ id: 'call-123', name: 'calculator', args: { expression: '2+2' }, type: 'tool_call' },
|
||||
],
|
||||
});
|
||||
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'calculator',
|
||||
toolInput: { expression: '2+2' },
|
||||
log: 'Calc',
|
||||
messageLog: [aiMessage],
|
||||
toolCallId: 'call-123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '4',
|
||||
},
|
||||
];
|
||||
|
||||
await saveToMemory('Calculate 2+2', 'The answer is 4', mockMemory, steps);
|
||||
|
||||
expect(mockChatHistory.addMessages).toHaveBeenCalledTimes(1);
|
||||
const savedMessages = mockChatHistory.addMessages.mock.calls[0][0];
|
||||
|
||||
expect(savedMessages).toHaveLength(4);
|
||||
expect(savedMessages[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(savedMessages[0].content).toBe('Calculate 2+2');
|
||||
expect(savedMessages[1]).toBe(aiMessage);
|
||||
expect(savedMessages[2]).toBeInstanceOf(ToolMessage);
|
||||
expect(savedMessages[3]).toBeInstanceOf(AIMessage);
|
||||
expect(savedMessages[3].content).toBe('The answer is 4');
|
||||
});
|
||||
|
||||
it('should fall back to string format when addMessages is not available', async () => {
|
||||
// Create a chat history object without addMessages method
|
||||
mockMemory.chatHistory = {} as any;
|
||||
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'calculator',
|
||||
toolInput: { expression: '2+2' },
|
||||
log: 'Calc',
|
||||
toolCallId: 'call-123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '4',
|
||||
},
|
||||
];
|
||||
|
||||
await saveToMemory('Calculate 2+2', 'The answer is 4', mockMemory, steps);
|
||||
|
||||
expect(mockMemory.saveContext).toHaveBeenCalledWith(
|
||||
{ input: 'Calculate 2+2' },
|
||||
{
|
||||
output:
|
||||
'[Used tools: Tool: calculator, Input: {"expression":"2+2"}, Result: 4] The answer is 4',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should use saveContext when steps array is empty', async () => {
|
||||
await saveToMemory('Simple question', 'Simple answer', mockMemory, []);
|
||||
|
||||
expect(mockMemory.saveContext).toHaveBeenCalledWith(
|
||||
{ input: 'Simple question' },
|
||||
{ output: 'Simple answer' },
|
||||
);
|
||||
expect(mockChatHistory.addMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use saveContext when steps is undefined', async () => {
|
||||
await saveToMemory('Simple question', 'Simple answer', mockMemory);
|
||||
|
||||
expect(mockMemory.saveContext).toHaveBeenCalledWith(
|
||||
{ input: 'Simple question' },
|
||||
{ output: 'Simple answer' },
|
||||
);
|
||||
expect(mockChatHistory.addMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use saveContext when all steps are from previous turns', async () => {
|
||||
const aiMessage = new AIMessage({
|
||||
content: 'Using tool',
|
||||
tool_calls: [{ id: 'call-123', name: 'calculator', args: {}, type: 'tool_call' }],
|
||||
});
|
||||
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'calculator',
|
||||
toolInput: { expression: '2+2' },
|
||||
log: 'Calc',
|
||||
messageLog: [aiMessage],
|
||||
toolCallId: 'call-123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '4',
|
||||
},
|
||||
];
|
||||
|
||||
// All steps are from previous turns (previousStepsCount = 1)
|
||||
await saveToMemory('New question', 'New answer', mockMemory, steps, 1);
|
||||
|
||||
expect(mockMemory.saveContext).toHaveBeenCalledWith(
|
||||
{ input: 'New question' },
|
||||
{ output: 'New answer' },
|
||||
);
|
||||
expect(mockChatHistory.addMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildToolContext', () => {
|
||||
it('should build tool context string from single step', () => {
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'calculator',
|
||||
toolInput: { expression: '2+2' },
|
||||
log: 'Using calculator',
|
||||
toolCallId: 'call_123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '4',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildToolContext(steps);
|
||||
|
||||
expect(result).toBe('Tool: calculator, Input: {"expression":"2+2"}, Result: 4');
|
||||
});
|
||||
|
||||
it('should build tool context string from multiple steps', () => {
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'weather',
|
||||
toolInput: { location: 'New York' },
|
||||
log: 'Getting weather',
|
||||
toolCallId: 'call_123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: 'Sunny, 72°F',
|
||||
},
|
||||
{
|
||||
action: {
|
||||
tool: 'time',
|
||||
toolInput: { timezone: 'EST' },
|
||||
log: 'Getting time',
|
||||
toolCallId: 'call_124',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '14:30',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildToolContext(steps);
|
||||
|
||||
expect(result).toBe(
|
||||
'Tool: weather, Input: {"location":"New York"}, Result: Sunny, 72°F; Tool: time, Input: {"timezone":"EST"}, Result: 14:30',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty string for empty steps array', () => {
|
||||
const result = buildToolContext([]);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle complex tool inputs', () => {
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'search',
|
||||
toolInput: {
|
||||
query: 'typescript testing',
|
||||
filters: { language: 'en', date: '2024' },
|
||||
limit: 10,
|
||||
},
|
||||
log: 'Searching',
|
||||
toolCallId: 'call_125',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: 'Found 10 results',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildToolContext(steps);
|
||||
|
||||
expect(result).toBe(
|
||||
'Tool: search, Input: {"query":"typescript testing","filters":{"language":"en","date":"2024"},"limit":10}, Result: Found 10 results',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type { EngineResponse, ExecuteNodeResult, IDataObject, ITaskData } from 'n8n-workflow';
|
||||
|
||||
import { processHitlResponses } from '../processHitlResponses';
|
||||
import type { HitlMetadata, RequestResponseMetadata } from '../types';
|
||||
|
||||
const createMockTaskData = (json: IDataObject): ITaskData => ({
|
||||
executionTime: 0,
|
||||
startTime: Date.now(),
|
||||
executionIndex: 0,
|
||||
source: [],
|
||||
data: {
|
||||
ai_tool: [[{ json }]],
|
||||
},
|
||||
});
|
||||
|
||||
const createHitlActionResponse = (
|
||||
approved: boolean,
|
||||
hitlMetadata: HitlMetadata,
|
||||
actionId = 'action-1',
|
||||
chatInput?: string,
|
||||
): ExecuteNodeResult<RequestResponseMetadata> => ({
|
||||
action: {
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'HITL Node',
|
||||
input: {},
|
||||
type: NodeConnectionTypes.AiTool,
|
||||
id: actionId,
|
||||
metadata: { hitl: hitlMetadata },
|
||||
},
|
||||
data: createMockTaskData({ approved, chatInput }),
|
||||
});
|
||||
|
||||
const createNonHitlActionResponse = (
|
||||
actionId = 'action-2',
|
||||
): ExecuteNodeResult<RequestResponseMetadata> => ({
|
||||
action: {
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'Regular Tool',
|
||||
input: {},
|
||||
type: NodeConnectionTypes.AiTool,
|
||||
id: actionId,
|
||||
metadata: {},
|
||||
},
|
||||
data: createMockTaskData({ result: 'success' }),
|
||||
});
|
||||
|
||||
describe('processHitlResponses', () => {
|
||||
const hitlMetadata = {
|
||||
gatedToolNodeName: 'Gated Tool Node',
|
||||
toolName: 'my_tool',
|
||||
originalInput: { query: 'test' },
|
||||
};
|
||||
|
||||
describe('empty/undefined responses', () => {
|
||||
it('returns empty result for undefined response', () => {
|
||||
const result = processHitlResponses(undefined, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(false);
|
||||
expect(result.pendingGatedToolRequest).toBeUndefined();
|
||||
expect(result.processedResponse.actionResponses).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty result for response with no action responses', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(false);
|
||||
expect(result.pendingGatedToolRequest).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-HITL responses', () => {
|
||||
it('passes through unchanged', () => {
|
||||
const actionResponse = createNonHitlActionResponse();
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [actionResponse],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(false);
|
||||
expect(result.processedResponse.actionResponses).toHaveLength(1);
|
||||
expect(result.processedResponse.actionResponses[0]).toEqual(actionResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approved HITL responses', () => {
|
||||
it('creates pending gated tool request', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [createHitlActionResponse(true, hitlMetadata)],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(true);
|
||||
expect(result.pendingGatedToolRequest).toBeDefined();
|
||||
expect(result.pendingGatedToolRequest?.actions).toHaveLength(1);
|
||||
|
||||
const action = result.pendingGatedToolRequest!.actions[0];
|
||||
expect(action.nodeName).toBe('Gated Tool Node');
|
||||
expect(action.input).toEqual({ query: 'test', tool: 'my_tool' });
|
||||
expect(action.id).toBe('action-1');
|
||||
expect(action.metadata?.parentNodeName).toBe('HITL Node');
|
||||
});
|
||||
|
||||
it('removes approved HITL response from processed responses', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [createHitlActionResponse(true, hitlMetadata)],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.processedResponse.actionResponses).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles nested approval data format', () => {
|
||||
const actionResponse: ExecuteNodeResult<RequestResponseMetadata> = {
|
||||
action: {
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'HITL Node',
|
||||
input: {},
|
||||
type: NodeConnectionTypes.AiTool,
|
||||
id: 'action-1',
|
||||
metadata: { hitl: hitlMetadata },
|
||||
},
|
||||
data: createMockTaskData({ data: { approved: true } }),
|
||||
};
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [actionResponse],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('denied HITL responses', () => {
|
||||
it('modifies response with denial message', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [createHitlActionResponse(false, hitlMetadata)],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(false);
|
||||
expect(result.pendingGatedToolRequest).toBeUndefined();
|
||||
expect(result.processedResponse.actionResponses).toHaveLength(1);
|
||||
|
||||
const processedData = result.processedResponse.actionResponses[0].data?.data
|
||||
?.ai_tool?.[0]?.[0]?.json as Record<string, unknown>;
|
||||
expect(processedData.output).toMatch(/reject/i);
|
||||
});
|
||||
it('modifies response with denial message and chat input', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [createHitlActionResponse(false, hitlMetadata, 'action-1', 'chat input')],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
expect(result.hasApprovedHitlTools).toBe(false);
|
||||
expect(result.pendingGatedToolRequest).toBeUndefined();
|
||||
expect(result.processedResponse.actionResponses).toHaveLength(1);
|
||||
|
||||
const processedData = result.processedResponse.actionResponses[0].data?.data
|
||||
?.ai_tool?.[0]?.[0]?.json as Record<string, unknown>;
|
||||
expect(processedData.output).toMatch(/chat input/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed responses', () => {
|
||||
it('processes HITL and non-HITL responses correctly', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [
|
||||
createNonHitlActionResponse('regular-1'),
|
||||
createHitlActionResponse(true, hitlMetadata, 'hitl-approved'),
|
||||
createHitlActionResponse(
|
||||
false,
|
||||
{ ...hitlMetadata, toolName: 'denied_tool' },
|
||||
'hitl-denied',
|
||||
),
|
||||
createNonHitlActionResponse('regular-2'),
|
||||
],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(true);
|
||||
expect(result.pendingGatedToolRequest?.actions).toHaveLength(1);
|
||||
// 2 non-HITL + 1 denied HITL = 3 (approved HITL is removed)
|
||||
expect(result.processedResponse.actionResponses).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple approvals', () => {
|
||||
it('batches multiple gated tool actions', () => {
|
||||
const hitlMetadata2 = { ...hitlMetadata, gatedToolNodeName: 'Another Gated Tool' };
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [
|
||||
createHitlActionResponse(true, hitlMetadata, 'hitl-1'),
|
||||
createHitlActionResponse(true, hitlMetadata2, 'hitl-2'),
|
||||
],
|
||||
metadata: { previousRequests: [{ action: {} as never, observation: 'prev' }] },
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(true);
|
||||
expect(result.pendingGatedToolRequest?.actions).toHaveLength(2);
|
||||
expect(result.pendingGatedToolRequest?.metadata?.previousRequests).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { serializeIntermediateSteps } from '../serializeIntermediateSteps';
|
||||
|
||||
describe('serializeIntermediateSteps', () => {
|
||||
it('should convert class instances with toJSON to plain objects', () => {
|
||||
const fakeAIMessage = {
|
||||
content: 'I need to call a tool',
|
||||
tool_calls: [{ name: 'TestTool', args: { input: 'test' }, id: 'call_123' }],
|
||||
additional_kwargs: {},
|
||||
response_metadata: { model: 'gpt-4' },
|
||||
id: 'msg_abc',
|
||||
name: undefined,
|
||||
toJSON() {
|
||||
return {
|
||||
lc: 1,
|
||||
type: 'constructor',
|
||||
id: ['langchain_core', 'messages', 'AIMessage'],
|
||||
kwargs: {
|
||||
content: this.content,
|
||||
tool_calls: this.tool_calls,
|
||||
additional_kwargs: this.additional_kwargs,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
action: {
|
||||
tool: 'TestTool',
|
||||
toolInput: { input: 'test' },
|
||||
log: 'Calling TestTool',
|
||||
messageLog: [fakeAIMessage],
|
||||
toolCallId: 'call_123',
|
||||
type: 'function',
|
||||
},
|
||||
observation: 'Tool result',
|
||||
},
|
||||
];
|
||||
|
||||
serializeIntermediateSteps(steps);
|
||||
|
||||
const serializedMsg = steps[0].action.messageLog[0] as Record<string, unknown>;
|
||||
|
||||
// Should be a plain object, not the original class instance
|
||||
expect(serializedMsg).not.toBe(fakeAIMessage);
|
||||
expect(typeof serializedMsg.toJSON).toBe('undefined');
|
||||
|
||||
// Direct property access should work
|
||||
expect(serializedMsg.content).toBe('I need to call a tool');
|
||||
expect(serializedMsg.tool_calls).toEqual([
|
||||
{ name: 'TestTool', args: { input: 'test' }, id: 'call_123' },
|
||||
]);
|
||||
expect(serializedMsg.additional_kwargs).toEqual({});
|
||||
expect(serializedMsg.response_metadata).toEqual({ model: 'gpt-4' });
|
||||
expect(serializedMsg.id).toBe('msg_abc');
|
||||
});
|
||||
|
||||
it('should leave plain objects unchanged', () => {
|
||||
const plainMsg = {
|
||||
content: 'Hello',
|
||||
tool_calls: [],
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
action: {
|
||||
tool: 'TestTool',
|
||||
toolInput: {},
|
||||
log: '',
|
||||
messageLog: [plainMsg],
|
||||
toolCallId: 'call_1',
|
||||
type: 'function',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
serializeIntermediateSteps(steps);
|
||||
|
||||
// Should be the same reference since it has no toJSON
|
||||
expect(steps[0].action.messageLog[0]).toBe(plainMsg);
|
||||
});
|
||||
|
||||
it('should handle steps without messageLog', () => {
|
||||
const steps = [
|
||||
{
|
||||
action: {
|
||||
tool: 'TestTool',
|
||||
toolInput: {},
|
||||
log: '',
|
||||
toolCallId: 'call_1',
|
||||
type: 'function',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
expect(() =>
|
||||
serializeIntermediateSteps(steps as Array<{ action: { messageLog?: unknown[] } }>),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle empty steps array', () => {
|
||||
const steps: Array<{ action: { messageLog?: unknown[] } }> = [];
|
||||
expect(() => serializeIntermediateSteps(steps)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should include type from _getType when not an own property', () => {
|
||||
const proto = {
|
||||
_getType() {
|
||||
return 'ai';
|
||||
},
|
||||
};
|
||||
const fakeMsg = Object.create(proto) as Record<string, unknown>;
|
||||
fakeMsg.content = 'test';
|
||||
fakeMsg.toJSON = () => ({ lc: 1, type: 'constructor', kwargs: {} });
|
||||
|
||||
const steps = [
|
||||
{
|
||||
action: {
|
||||
messageLog: [fakeMsg],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
serializeIntermediateSteps(steps as Array<{ action: { messageLog?: unknown[] } }>);
|
||||
|
||||
const serialized = steps[0].action.messageLog[0] as Record<string, unknown>;
|
||||
expect(serialized.type).toBe('ai');
|
||||
expect(serialized.content).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle mixed messageLog entries', () => {
|
||||
const classInstance = {
|
||||
content: 'from class',
|
||||
toJSON() {
|
||||
return { kwargs: { content: this.content } };
|
||||
},
|
||||
};
|
||||
const plainObject = { content: 'from plain' };
|
||||
const primitiveValue = 'just a string';
|
||||
|
||||
const steps = [
|
||||
{
|
||||
action: {
|
||||
messageLog: [classInstance, plainObject, primitiveValue],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
serializeIntermediateSteps(steps as Array<{ action: { messageLog?: unknown[] } }>);
|
||||
|
||||
const [serializedClass, unchangedPlain, unchangedPrimitive] = steps[0].action.messageLog;
|
||||
|
||||
// Class instance should be serialized
|
||||
expect(serializedClass).not.toBe(classInstance);
|
||||
expect((serializedClass as Record<string, unknown>).content).toBe('from class');
|
||||
expect(typeof (serializedClass as Record<string, unknown>).toJSON).toBe('undefined');
|
||||
|
||||
// Plain object should be unchanged
|
||||
expect(unchangedPlain).toBe(plainObject);
|
||||
|
||||
// Primitive should be unchanged
|
||||
expect(unchangedPrimitive).toBe(primitiveValue);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user