mirror of https://github.com/buster-so/buster.git
229 lines
7.9 KiB
TypeScript
229 lines
7.9 KiB
TypeScript
import type { CoreMessage } from 'ai';
|
|
import { NoSuchToolError } from 'ai';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { retryableAgentStreamWithHealing } from '../../../src/utils/retry';
|
|
import type { MastraAgent } from '../../../src/utils/retry/types';
|
|
|
|
/**
|
|
* Unit test to prove healing mechanism works without running full agents
|
|
*/
|
|
describe('Healing Mechanism Unit Test - Definitive Proof', () => {
|
|
it('PROOF: onError callback receives tool errors and returns healing response', async () => {
|
|
// Create a mock agent that will capture the onError callback
|
|
let capturedOnError: ((error: unknown) => unknown) | undefined;
|
|
let simulatedToolError: NoSuchToolError | undefined = undefined;
|
|
|
|
const mockAgent: MastraAgent = {
|
|
name: 'test-agent',
|
|
description: 'Test agent',
|
|
model: {} as any,
|
|
tools: {
|
|
'valid-tool': {} as any,
|
|
'another-tool': {} as any,
|
|
},
|
|
stream: vi.fn().mockImplementation(async (messages, options) => {
|
|
// Capture the onError callback from options
|
|
capturedOnError = options?.onError;
|
|
|
|
// Create a mock stream that will trigger the error
|
|
const mockStream = {
|
|
fullStream: {
|
|
async *[Symbol.asyncIterator]() {
|
|
// Yield some initial chunks
|
|
yield { type: 'text-delta', text: 'Starting...' };
|
|
|
|
// Now simulate a tool error occurring during streaming
|
|
if (capturedOnError && simulatedToolError) {
|
|
const healingResponse = capturedOnError(simulatedToolError);
|
|
|
|
// The healing response should be returned to the LLM
|
|
expect(healingResponse).toBeDefined();
|
|
expect(healingResponse).toHaveProperty('error');
|
|
|
|
// Yield the healing response as if it were a tool result
|
|
yield {
|
|
type: 'tool-result',
|
|
toolCallId: simulatedToolError.toolCallId,
|
|
toolName: simulatedToolError.toolName,
|
|
result: healingResponse,
|
|
};
|
|
}
|
|
|
|
// Continue with more chunks after healing
|
|
yield { type: 'text-delta', text: 'Continuing after healing...' };
|
|
},
|
|
},
|
|
};
|
|
|
|
return mockStream as any;
|
|
}),
|
|
} as any;
|
|
|
|
const messages: CoreMessage[] = [{ role: 'user', content: 'Test message' }];
|
|
|
|
// Create the tool error we'll simulate
|
|
simulatedToolError = new NoSuchToolError({
|
|
toolName: 'non-existent-tool',
|
|
availableTools: ['valid-tool', 'another-tool'],
|
|
});
|
|
(simulatedToolError as any).toolCallId = 'test-call-123';
|
|
|
|
let healingOccurred = false;
|
|
let healingMessage = '';
|
|
|
|
const result = await retryableAgentStreamWithHealing({
|
|
agent: mockAgent,
|
|
messages,
|
|
options: {
|
|
toolCallStreaming: true,
|
|
runtimeContext: {} as any, // Mock context
|
|
},
|
|
retryConfig: {
|
|
maxRetries: 3,
|
|
onRetry: (error, attempt) => {
|
|
healingOccurred = true;
|
|
if (error.healingMessage.role === 'tool' && Array.isArray(error.healingMessage.content)) {
|
|
const toolResult = error.healingMessage.content[0];
|
|
if ('result' in toolResult && toolResult.result && 'error' in toolResult.result) {
|
|
healingMessage = toolResult.result.error as string;
|
|
}
|
|
}
|
|
},
|
|
},
|
|
});
|
|
|
|
// Verify the stream was created
|
|
expect(result.stream).toBeDefined();
|
|
expect(mockAgent.stream).toHaveBeenCalledTimes(1);
|
|
|
|
// Verify onError callback was passed to the agent
|
|
expect(capturedOnError).toBeDefined();
|
|
|
|
// Test the onError callback directly
|
|
const errorResponse = capturedOnError!(simulatedToolError);
|
|
|
|
// THIS IS THE PROOF: The error response is returned to heal the agent
|
|
expect(errorResponse).toEqual({
|
|
error:
|
|
'Tool "non-existent-tool" is not available. Available tools: valid-tool, another-tool. Please use one of the available tools instead.',
|
|
});
|
|
|
|
// Verify healing was tracked
|
|
expect(healingOccurred).toBe(true);
|
|
expect(healingMessage).toContain('Tool "non-existent-tool" is not available');
|
|
expect(healingMessage).toContain('valid-tool');
|
|
expect(healingMessage).toContain('another-tool');
|
|
|
|
console.log('✅ HEALING MECHANISM PROVEN TO WORK!');
|
|
console.log(' - onError callback successfully captures tool errors');
|
|
console.log(' - Healing response is returned to the LLM');
|
|
console.log(' - Agent can continue execution after healing');
|
|
});
|
|
|
|
it('PROOF: Multiple error types are handled correctly', async () => {
|
|
const mockAgent: MastraAgent = {
|
|
stream: vi.fn().mockResolvedValue({ fullStream: [] }),
|
|
} as any;
|
|
|
|
let capturedOnError: ((error: unknown) => unknown) | undefined;
|
|
|
|
await retryableAgentStreamWithHealing({
|
|
agent: mockAgent,
|
|
messages: [{ role: 'user', content: 'Test' }],
|
|
options: {
|
|
runtimeContext: {} as any,
|
|
onError: (cb) => {
|
|
capturedOnError = cb;
|
|
},
|
|
},
|
|
});
|
|
|
|
// Get the onError from the mock call
|
|
const mockCall = vi.mocked(mockAgent.stream).mock.calls[0];
|
|
capturedOnError = mockCall[1]?.onError;
|
|
|
|
expect(capturedOnError).toBeDefined();
|
|
|
|
// Test 1: NoSuchToolError
|
|
const noSuchToolError = new NoSuchToolError({
|
|
toolName: 'bad-tool',
|
|
availableTools: ['good-tool'],
|
|
});
|
|
(noSuchToolError as any).toolCallId = 'call1';
|
|
|
|
const response1 = capturedOnError!(noSuchToolError);
|
|
expect(response1).toHaveProperty('error');
|
|
expect((response1 as any).error).toContain('Tool "bad-tool" is not available');
|
|
|
|
// Test 2: InvalidToolArgumentsError
|
|
const invalidArgsError = new Error('Invalid args');
|
|
invalidArgsError.name = 'AI_InvalidToolArgumentsError';
|
|
(invalidArgsError as any).toolCallId = 'call2';
|
|
(invalidArgsError as any).toolName = 'some-tool';
|
|
(invalidArgsError as any).cause = {
|
|
errors: [
|
|
{ path: ['field1'], message: 'Required' },
|
|
{ path: ['field2'], message: 'Must be number' },
|
|
],
|
|
};
|
|
|
|
const response2 = capturedOnError!(invalidArgsError);
|
|
expect(response2).toHaveProperty('error');
|
|
expect((response2 as any).error).toContain('Invalid tool arguments');
|
|
expect((response2 as any).error).toContain('field1: Required');
|
|
expect((response2 as any).error).toContain('field2: Must be number');
|
|
|
|
// Test 3: Non-healable error returns undefined
|
|
const genericError = new Error('Some other error');
|
|
const response3 = capturedOnError!(genericError);
|
|
expect(response3).toBeUndefined();
|
|
|
|
console.log('✅ ALL ERROR TYPES HANDLED CORRECTLY!');
|
|
});
|
|
|
|
it('PROOF: Healing attempts are limited by maxRetries', async () => {
|
|
const mockAgent: MastraAgent = {
|
|
stream: vi.fn().mockResolvedValue({ fullStream: [] }),
|
|
} as any;
|
|
|
|
let healingAttempts = 0;
|
|
|
|
await retryableAgentStreamWithHealing({
|
|
agent: mockAgent,
|
|
messages: [{ role: 'user', content: 'Test' }],
|
|
options: { runtimeContext: {} as any },
|
|
retryConfig: {
|
|
maxRetries: 2,
|
|
onRetry: () => {
|
|
healingAttempts++;
|
|
},
|
|
},
|
|
});
|
|
|
|
const capturedOnError = vi.mocked(mockAgent.stream).mock.calls[0][1]?.onError;
|
|
expect(capturedOnError).toBeDefined();
|
|
|
|
const error = new NoSuchToolError({
|
|
toolName: 'test',
|
|
availableTools: [],
|
|
});
|
|
|
|
// First attempt - should heal
|
|
let response = capturedOnError!(error);
|
|
expect(response).toBeDefined();
|
|
expect(healingAttempts).toBe(1);
|
|
|
|
// Second attempt - should heal
|
|
response = capturedOnError!(error);
|
|
expect(response).toBeDefined();
|
|
expect(healingAttempts).toBe(2);
|
|
|
|
// Third attempt - should NOT heal (exceeds maxRetries)
|
|
response = capturedOnError!(error);
|
|
expect(response).toBeUndefined();
|
|
expect(healingAttempts).toBe(2); // No additional retry
|
|
|
|
console.log('✅ MAX RETRIES LIMIT PROVEN TO WORK!');
|
|
});
|
|
});
|