146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
/**
|
|
* Tests for MemoryManager.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
// Mock pi-ai complete function
|
|
vi.mock('@mariozechner/pi-ai', () => ({
|
|
complete: vi.fn().mockResolvedValue({
|
|
content: [{ type: 'text', text: '{"summary": "Steps 1-5: navigated and clicked"}' }],
|
|
}),
|
|
getModel: vi.fn().mockReturnValue({ provider: 'mock', model: 'mock-model' }),
|
|
}));
|
|
|
|
import { MemoryManager } from '../../src/agent/memoryManager.js';
|
|
import { getModel } from '@mariozechner/pi-ai';
|
|
|
|
describe('MemoryManager', () => {
|
|
let mm: MemoryManager;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const model = getModel('anthropic' as any, 'claude-sonnet-4-20250514' as any);
|
|
mm = new MemoryManager(model, 'test-key');
|
|
});
|
|
|
|
describe('addPendingStep + compose', () => {
|
|
it('should add pending step and include it in compose', () => {
|
|
mm.addPendingStep(1, 'Click button');
|
|
const text = mm.compose();
|
|
expect(text).toContain('[Current step]');
|
|
expect(text).toContain('Step 1');
|
|
expect(text).toContain('Click button');
|
|
});
|
|
});
|
|
|
|
describe('finalizeStep', () => {
|
|
it('should move pending to recent on finalize', async () => {
|
|
mm.addPendingStep(1, 'Click button');
|
|
await mm.finalizeStep(1, 'success');
|
|
|
|
const text = mm.compose();
|
|
expect(text).toContain('[Recent steps]');
|
|
expect(text).toContain('Eval: success');
|
|
expect(text).not.toContain('[Current step]');
|
|
});
|
|
|
|
it('should record failed evaluation', async () => {
|
|
mm.addPendingStep(1, 'Click button');
|
|
await mm.finalizeStep(1, 'failed');
|
|
|
|
const text = mm.compose();
|
|
expect(text).toContain('Eval: failed');
|
|
});
|
|
|
|
it('should accumulate multiple steps in recent', async () => {
|
|
mm.addPendingStep(1, 'Step one');
|
|
await mm.finalizeStep(1, 'success');
|
|
mm.addPendingStep(2, 'Step two');
|
|
await mm.finalizeStep(2, 'success');
|
|
|
|
const text = mm.compose();
|
|
expect(text).toContain('Step 1');
|
|
expect(text).toContain('Step 2');
|
|
});
|
|
});
|
|
|
|
describe('compression', () => {
|
|
it('should trigger compression when recent exceeds budget', async () => {
|
|
const { complete } = await import('@mariozechner/pi-ai');
|
|
|
|
// Add many steps to exceed the 500 char budget
|
|
for (let i = 1; i <= 20; i++) {
|
|
mm.addPendingStep(i, `Goal for step ${i}: perform a complex multi-word action description`);
|
|
await mm.finalizeStep(i, 'success');
|
|
}
|
|
|
|
// complete() should have been called for summarization
|
|
expect(complete).toHaveBeenCalled();
|
|
|
|
// After compression, compose should still work
|
|
const text = mm.compose();
|
|
expect(text.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('pruneScreenshots', () => {
|
|
it('should keep all screenshots when under limit', () => {
|
|
const messages = [
|
|
{ role: 'user', content: [{ type: 'image', data: 'img1', mimeType: 'image/jpeg' }] },
|
|
{ role: 'user', content: [{ type: 'image', data: 'img2', mimeType: 'image/jpeg' }] },
|
|
];
|
|
|
|
const result = mm.pruneScreenshots(messages);
|
|
expect(result).toEqual(messages); // No pruning — under screenshotKeepCount (3)
|
|
});
|
|
|
|
it('should replace old screenshots with placeholders', () => {
|
|
const messages = [
|
|
{ role: 'user', content: [{ type: 'image', data: 'img1', mimeType: 'image/jpeg' }] },
|
|
{ role: 'user', content: [{ type: 'image', data: 'img2', mimeType: 'image/jpeg' }] },
|
|
{ role: 'user', content: [{ type: 'image', data: 'img3', mimeType: 'image/jpeg' }] },
|
|
{ role: 'user', content: [{ type: 'image', data: 'img4', mimeType: 'image/jpeg' }] },
|
|
{ role: 'user', content: [{ type: 'image', data: 'img5', mimeType: 'image/jpeg' }] },
|
|
];
|
|
|
|
const result = mm.pruneScreenshots(messages);
|
|
|
|
// First 2 should be replaced (5 - 3 = keep from index 2)
|
|
expect((result[0] as any).content[0].type).toBe('text');
|
|
expect((result[0] as any).content[0].text).toContain('Screenshot removed');
|
|
expect((result[1] as any).content[0].type).toBe('text');
|
|
|
|
// Last 3 should be kept
|
|
expect((result[2] as any).content[0].type).toBe('image');
|
|
expect((result[3] as any).content[0].type).toBe('image');
|
|
expect((result[4] as any).content[0].type).toBe('image');
|
|
});
|
|
|
|
it('should not modify non-image messages', () => {
|
|
const messages = [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
|
{ role: 'user', content: [{ type: 'image', data: 'img1', mimeType: 'image/jpeg' }] },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'response' }] },
|
|
];
|
|
|
|
const result = mm.pruneScreenshots(messages);
|
|
expect(result).toEqual(messages); // Only 1 image, under limit
|
|
});
|
|
});
|
|
|
|
describe('compose layers', () => {
|
|
it('should return empty string when no memory', () => {
|
|
expect(mm.compose()).toBe('');
|
|
});
|
|
|
|
it('should compose all three layers', () => {
|
|
// We can't easily set summaryMemory directly, but we can verify
|
|
// pending + recent compose correctly
|
|
mm.addPendingStep(1, 'Current action');
|
|
const text = mm.compose();
|
|
expect(text).toContain('[Current step]');
|
|
});
|
|
});
|
|
});
|