2025-07-09 06:25:15 +08:00
|
|
|
import type { CoreMessage } from 'ai';
|
2025-07-09 21:44:10 +08:00
|
|
|
import {
|
|
|
|
APICallError,
|
|
|
|
EmptyResponseBodyError,
|
|
|
|
InvalidToolArgumentsError,
|
|
|
|
JSONParseError,
|
|
|
|
NoSuchToolError,
|
2025-07-09 06:25:15 +08:00
|
|
|
} from 'ai';
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
|
|
applyHealingStrategy,
|
|
|
|
determineHealingStrategy,
|
|
|
|
shouldRetryWithoutHealing,
|
|
|
|
} from '../../../src/utils/retry/healing-strategies';
|
2025-07-09 21:44:10 +08:00
|
|
|
import { detectRetryableError } from '../../../src/utils/retry/retry-agent-stream';
|
|
|
|
import { RetryWithHealingError } from '../../../src/utils/retry/retry-error';
|
|
|
|
import { createRetryOnErrorHandler } from '../../../src/utils/retry/retry-helpers';
|
2025-07-09 06:25:15 +08:00
|
|
|
import type { WorkflowContext } from '../../../src/utils/retry/types';
|
|
|
|
|
|
|
|
describe('Healing Behavior - Different Error Types', () => {
|
|
|
|
describe('EmptyResponseBodyError - Should Remove Bad Message', () => {
|
|
|
|
it('should remove empty assistant response and ask to continue', () => {
|
|
|
|
const messages: CoreMessage[] = [
|
|
|
|
{ role: 'user', content: 'Analyze my revenue data' },
|
|
|
|
{ role: 'assistant', content: '' }, // Empty response that caused error
|
|
|
|
];
|
|
|
|
|
|
|
|
const error = new EmptyResponseBodyError({
|
|
|
|
message: 'Empty response body',
|
|
|
|
});
|
|
|
|
|
|
|
|
const retryableError = detectRetryableError(error);
|
|
|
|
expect(retryableError).not.toBeNull();
|
|
|
|
expect(retryableError?.type).toBe('empty-response');
|
|
|
|
|
|
|
|
const strategy = determineHealingStrategy(retryableError!);
|
|
|
|
expect(strategy.shouldRemoveLastAssistantMessage).toBe(true);
|
|
|
|
expect(strategy.healingMessage?.content).toBe('Please continue with your analysis.');
|
|
|
|
|
|
|
|
const healedMessages = applyHealingStrategy(messages, strategy);
|
|
|
|
expect(healedMessages).toHaveLength(2);
|
|
|
|
expect(healedMessages[0]?.content).toBe('Analyze my revenue data');
|
|
|
|
expect(healedMessages[1]?.content).toBe('Please continue with your analysis.');
|
2025-07-09 21:44:10 +08:00
|
|
|
|
2025-07-09 06:25:15 +08:00
|
|
|
// The empty assistant message should be gone
|
2025-07-09 21:44:10 +08:00
|
|
|
expect(
|
|
|
|
healedMessages.find((m) => m.role === 'assistant' && m.content === '')
|
|
|
|
).toBeUndefined();
|
2025-07-09 06:25:15 +08:00
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe('JSONParseError - Should Remove Malformed Message', () => {
|
|
|
|
it('should remove malformed JSON response and retry', () => {
|
|
|
|
const messages: CoreMessage[] = [
|
|
|
|
{ role: 'user', content: 'Create a metric' },
|
2025-07-09 21:44:10 +08:00
|
|
|
{
|
|
|
|
role: 'assistant',
|
2025-07-09 06:25:15 +08:00
|
|
|
content: [
|
|
|
|
{ type: 'text', text: 'Creating metric...' },
|
2025-07-09 21:44:10 +08:00
|
|
|
{
|
|
|
|
type: 'tool-call',
|
|
|
|
toolCallId: '123',
|
2025-07-09 06:25:15 +08:00
|
|
|
toolName: 'createMetrics',
|
2025-07-09 21:44:10 +08:00
|
|
|
args: '{"name": "revenue", "expression": ', // Incomplete JSON
|
|
|
|
},
|
|
|
|
],
|
2025-07-09 06:25:15 +08:00
|
|
|
},
|
|
|
|
];
|
|
|
|
|
|
|
|
const error = new JSONParseError({
|
|
|
|
message: 'Invalid JSON',
|
|
|
|
text: '{"name": "revenue", "expression": ',
|
|
|
|
cause: new SyntaxError('Unexpected end of JSON input'),
|
|
|
|
});
|
|
|
|
|
|
|
|
const retryableError = detectRetryableError(error);
|
|
|
|
expect(retryableError?.type).toBe('json-parse-error');
|
|
|
|
|
|
|
|
const strategy = determineHealingStrategy(retryableError!);
|
|
|
|
expect(strategy.shouldRemoveLastAssistantMessage).toBe(true);
|
|
|
|
|
|
|
|
const healedMessages = applyHealingStrategy(messages, strategy);
|
|
|
|
expect(healedMessages).toHaveLength(2);
|
|
|
|
expect(healedMessages[1]?.content).toBe('Please continue with your analysis.');
|
2025-07-09 21:44:10 +08:00
|
|
|
|
2025-07-09 06:25:15 +08:00
|
|
|
// The malformed assistant message should be removed
|
2025-07-09 21:44:10 +08:00
|
|
|
expect(healedMessages.find((m) => m.role === 'assistant')).toBeUndefined();
|
2025-07-09 06:25:15 +08:00
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe('NoSuchToolError - Should Add Healing Without Removing', () => {
|
|
|
|
it('should keep the tool attempt and add healing message', () => {
|
|
|
|
const messages: CoreMessage[] = [
|
|
|
|
{ role: 'user', content: 'Create a dashboard' },
|
2025-07-09 21:44:10 +08:00
|
|
|
{
|
|
|
|
role: 'assistant',
|
2025-07-09 06:25:15 +08:00
|
|
|
content: [
|
2025-07-09 21:44:10 +08:00
|
|
|
{
|
|
|
|
type: 'tool-call',
|
|
|
|
toolCallId: 'call_123',
|
2025-07-09 06:25:15 +08:00
|
|
|
toolName: 'createDashboards',
|
2025-07-09 21:44:10 +08:00
|
|
|
args: { name: 'Revenue Dashboard' },
|
|
|
|
},
|
|
|
|
],
|
2025-07-09 06:25:15 +08:00
|
|
|
},
|
|
|
|
];
|
|
|
|
|
|
|
|
const error = new NoSuchToolError({
|
|
|
|
toolName: 'createDashboards',
|
|
|
|
availableTools: ['sequentialThinking', 'executeSql', 'submitThoughts'],
|
|
|
|
});
|
|
|
|
|
|
|
|
const context: WorkflowContext = { currentStep: 'think-and-prep' };
|
|
|
|
const retryableError = detectRetryableError(error, context);
|
|
|
|
expect(retryableError?.type).toBe('no-such-tool');
|
|
|
|
|
|
|
|
const strategy = determineHealingStrategy(retryableError!);
|
|
|
|
expect(strategy.shouldRemoveLastAssistantMessage).toBe(false);
|
|
|
|
expect(strategy.healingMessage).toBeDefined();
|
|
|
|
|
|
|
|
const healedMessages = applyHealingStrategy(messages, strategy);
|
|
|
|
expect(healedMessages).toHaveLength(3);
|
2025-07-09 21:44:10 +08:00
|
|
|
|
2025-07-09 06:25:15 +08:00
|
|
|
// Original messages should still be there
|
|
|
|
expect(healedMessages[0]?.content).toBe('Create a dashboard');
|
|
|
|
expect(healedMessages[1]?.role).toBe('assistant');
|
2025-07-09 21:44:10 +08:00
|
|
|
|
2025-07-09 06:25:15 +08:00
|
|
|
// Healing message should be added
|
|
|
|
expect(healedMessages[2]?.role).toBe('tool');
|
|
|
|
expect(healedMessages[2]?.content[0].type).toBe('tool-result');
|
2025-07-09 21:44:10 +08:00
|
|
|
expect(healedMessages[2]?.content[0].result.error).toContain(
|
|
|
|
'Tool "createDashboards" is not available'
|
|
|
|
);
|
2025-07-09 06:25:15 +08:00
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe('InvalidToolArgumentsError - Should Add Error Result', () => {
|
|
|
|
it('should keep the tool call and add error result', () => {
|
|
|
|
const messages: CoreMessage[] = [
|
|
|
|
{ role: 'user', content: 'Query the database' },
|
2025-07-09 21:44:10 +08:00
|
|
|
{
|
|
|
|
role: 'assistant',
|
2025-07-09 06:25:15 +08:00
|
|
|
content: [
|
2025-07-09 21:44:10 +08:00
|
|
|
{
|
|
|
|
type: 'tool-call',
|
|
|
|
toolCallId: 'call_456',
|
2025-07-09 06:25:15 +08:00
|
|
|
toolName: 'executeSql',
|
2025-07-09 21:44:10 +08:00
|
|
|
args: { query: 123 }, // Wrong type
|
|
|
|
},
|
|
|
|
],
|
2025-07-09 06:25:15 +08:00
|
|
|
},
|
|
|
|
];
|
|
|
|
|
|
|
|
const error = new InvalidToolArgumentsError({
|
|
|
|
toolName: 'executeSql',
|
|
|
|
toolCallId: 'call_456',
|
|
|
|
args: { query: 123 },
|
|
|
|
cause: {
|
2025-07-09 21:44:10 +08:00
|
|
|
errors: [{ path: ['query'], message: 'Expected string, received number' }],
|
2025-07-09 06:25:15 +08:00
|
|
|
},
|
|
|
|
});
|
|
|
|
(error as any).name = 'AI_InvalidToolArgumentsError';
|
|
|
|
(error as any).toolCallId = 'call_456';
|
|
|
|
|
|
|
|
const retryableError = detectRetryableError(error);
|
|
|
|
expect(retryableError?.type).toBe('invalid-tool-arguments');
|
|
|
|
|
|
|
|
const strategy = determineHealingStrategy(retryableError!);
|
|
|
|
expect(strategy.shouldRemoveLastAssistantMessage).toBe(false);
|
|
|
|
|
|
|
|
const healedMessages = applyHealingStrategy(messages, strategy);
|
|
|
|
expect(healedMessages).toHaveLength(3);
|
2025-07-09 21:44:10 +08:00
|
|
|
|
2025-07-09 06:25:15 +08:00
|
|
|
// Tool error result should be added
|
|
|
|
const toolResult = healedMessages[2];
|
|
|
|
expect(toolResult?.role).toBe('tool');
|
2025-07-09 21:44:10 +08:00
|
|
|
expect(toolResult?.content[0].result.error).toContain(
|
|
|
|
'query: Expected string, received number'
|
|
|
|
);
|
2025-07-09 06:25:15 +08:00
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe('Network/Server Errors - Should Retry Without Modification', () => {
|
|
|
|
it('should retry network errors without healing', () => {
|
|
|
|
const messages: CoreMessage[] = [
|
|
|
|
{ role: 'user', content: 'Analyze data' },
|
|
|
|
{ role: 'assistant', content: 'Starting analysis...' },
|
|
|
|
];
|
|
|
|
|
|
|
|
const error = new APICallError({
|
|
|
|
message: 'Network timeout',
|
|
|
|
statusCode: undefined,
|
|
|
|
responseHeaders: {},
|
|
|
|
responseBody: undefined,
|
|
|
|
url: 'https://api.example.com',
|
|
|
|
requestBodyValues: {},
|
|
|
|
cause: new Error('ETIMEDOUT'),
|
|
|
|
isRetryable: true,
|
|
|
|
});
|
|
|
|
|
|
|
|
const retryableError = detectRetryableError(error);
|
|
|
|
expect(retryableError?.type).toBe('network-timeout');
|
|
|
|
|
|
|
|
const strategy = determineHealingStrategy(retryableError!);
|
|
|
|
expect(strategy.shouldRemoveLastAssistantMessage).toBe(false);
|
|
|
|
expect(strategy.healingMessage).toBeNull();
|
|
|
|
expect(strategy.backoffMultiplier).toBe(2);
|
|
|
|
|
|
|
|
expect(shouldRetryWithoutHealing(retryableError!.type)).toBe(true);
|
|
|
|
|
|
|
|
// Messages should remain unchanged for network errors
|
|
|
|
const healedMessages = applyHealingStrategy(messages, strategy);
|
|
|
|
expect(healedMessages).toEqual(messages);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should handle rate limit errors with longer backoff', () => {
|
|
|
|
const error = new APICallError({
|
|
|
|
message: 'Rate limit exceeded',
|
|
|
|
statusCode: 429,
|
|
|
|
responseHeaders: { 'retry-after': '60' },
|
|
|
|
responseBody: 'Too many requests',
|
|
|
|
url: 'https://api.example.com',
|
|
|
|
requestBodyValues: {},
|
|
|
|
cause: undefined,
|
|
|
|
isRetryable: true,
|
|
|
|
});
|
|
|
|
|
|
|
|
const retryableError = detectRetryableError(error);
|
|
|
|
expect(retryableError?.type).toBe('rate-limit');
|
|
|
|
|
|
|
|
const strategy = determineHealingStrategy(retryableError!);
|
|
|
|
expect(strategy.backoffMultiplier).toBe(3); // Longer backoff for rate limits
|
|
|
|
expect(shouldRetryWithoutHealing('rate-limit')).toBe(true);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe('Complete onError Flow with Healing', () => {
|
|
|
|
it('should handle empty response error correctly', async () => {
|
|
|
|
const handler = createRetryOnErrorHandler({
|
|
|
|
retryCount: 0,
|
|
|
|
maxRetries: 5,
|
|
|
|
workflowContext: { currentStep: 'analyst' },
|
|
|
|
});
|
|
|
|
|
|
|
|
const error = new EmptyResponseBodyError({
|
|
|
|
message: 'Empty response body',
|
|
|
|
});
|
|
|
|
|
|
|
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
|
|
|
|
|
|
let thrownError: any;
|
|
|
|
try {
|
|
|
|
await handler({ error });
|
|
|
|
} catch (e) {
|
|
|
|
thrownError = e;
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(thrownError).toBeInstanceOf(RetryWithHealingError);
|
|
|
|
expect(thrownError.retryableError.type).toBe('empty-response');
|
2025-07-09 21:44:10 +08:00
|
|
|
|
2025-07-09 06:25:15 +08:00
|
|
|
// The healing message should be a simple "continue" message
|
|
|
|
expect(thrownError.retryableError.healingMessage.role).toBe('user');
|
|
|
|
expect(thrownError.retryableError.healingMessage.content).toBe('Please continue.');
|
|
|
|
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
|
|
consoleInfoSpy.mockRestore();
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should handle JSON parse error correctly', async () => {
|
|
|
|
const handler = createRetryOnErrorHandler({
|
|
|
|
retryCount: 0,
|
|
|
|
maxRetries: 5,
|
|
|
|
workflowContext: { currentStep: 'think-and-prep' },
|
|
|
|
});
|
|
|
|
|
|
|
|
const error = new JSONParseError({
|
|
|
|
message: 'Invalid JSON',
|
|
|
|
text: '{"incomplete":',
|
|
|
|
cause: new SyntaxError('Unexpected end of JSON input'),
|
|
|
|
});
|
|
|
|
|
|
|
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
|
|
|
|
|
|
let thrownError: any;
|
|
|
|
try {
|
|
|
|
await handler({ error });
|
|
|
|
} catch (e) {
|
|
|
|
thrownError = e;
|
|
|
|
}
|
|
|
|
|
|
|
|
expect(thrownError).toBeInstanceOf(RetryWithHealingError);
|
|
|
|
expect(thrownError.retryableError.type).toBe('json-parse-error');
|
|
|
|
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
|
|
consoleInfoSpy.mockRestore();
|
|
|
|
});
|
|
|
|
});
|
2025-07-09 21:44:10 +08:00
|
|
|
});
|