added in the slack message tracking

This commit is contained in:
dal 2025-07-09 07:44:10 -06:00
parent 59c19354b9
commit 897ac4ce83
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
10 changed files with 267 additions and 175 deletions

View File

@ -1,4 +1,13 @@
import { and, eq, getDb, getSecretByName, isNull, slackIntegrations } from '@buster/database';
import {
and,
eq,
getDb,
getSecretByName,
isNull,
messagesToSlackMessages,
slackIntegrations,
slackMessageTracking,
} from '@buster/database';
import { logger } from '@trigger.dev/sdk/v3';
export interface SlackNotificationParams {
@ -15,6 +24,9 @@ export interface SlackNotificationParams {
export interface SlackNotificationResult {
sent: boolean;
error?: string;
messageTs?: string;
integrationId?: string;
channelId?: string;
}
interface SlackBlock {
@ -106,7 +118,12 @@ export async function sendSlackNotification(
channelId: integration.defaultChannel.id,
messageTs: result.messageTs,
});
return { sent: true };
return {
sent: true,
...(result.messageTs && { messageTs: result.messageTs }),
integrationId: integration.id,
channelId: integration.defaultChannel.id,
};
}
logger.error('Failed to send Slack notification', {
@ -274,3 +291,65 @@ async function sendSlackMessage(
};
}
}
/**
* Track a sent Slack notification in the database
*/
export async function trackSlackNotification(params: {
messageId: string;
integrationId: string;
channelId: string;
messageTs: string;
userName: string | null;
chatId: string;
summaryTitle?: string;
summaryMessage?: string;
}): Promise<void> {
const db = getDb();
try {
await db.transaction(async (tx) => {
// Insert into slack_message_tracking
const [slackMessage] = await tx
.insert(slackMessageTracking)
.values({
integrationId: params.integrationId,
internalMessageId: params.messageId,
slackChannelId: params.channelId,
slackMessageTs: params.messageTs,
slackThreadTs: null,
messageType: 'message',
content:
params.summaryTitle && params.summaryMessage
? `${params.summaryTitle}\n\n${params.summaryMessage}`
: 'Notification sent',
senderInfo: {
sentBy: 'buster-post-processing',
userName: params.userName,
chatId: params.chatId,
},
sentAt: new Date().toISOString(),
})
.returning();
// Create association in messages_to_slack_messages
if (slackMessage) {
await tx.insert(messagesToSlackMessages).values({
messageId: params.messageId,
slackMessageId: slackMessage.id,
});
}
});
logger.log('Successfully tracked Slack notification', {
messageId: params.messageId,
integrationId: params.integrationId,
});
} catch (error) {
// Log but don't throw - tracking failure shouldn't break the flow
logger.error('Failed to track Slack notification', {
messageId: params.messageId,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}

View File

@ -18,6 +18,7 @@ import {
fetchPreviousPostProcessingMessages,
fetchUserDatasets,
sendSlackNotification,
trackSlackNotification,
} from './helpers';
import { DataFetchError, MessageNotFoundError, TaskInputSchema } from './types';
import type { TaskInput, TaskOutput } from './types';
@ -302,6 +303,20 @@ export const messagePostProcessingTask: ReturnType<
messageId: payload.messageId,
organizationId: messageContext.organizationId,
});
// Track the sent notification
if (slackResult.messageTs && slackResult.integrationId && slackResult.channelId) {
await trackSlackNotification({
messageId: payload.messageId,
integrationId: slackResult.integrationId,
channelId: slackResult.channelId,
messageTs: slackResult.messageTs,
userName: messageContext.userName,
chatId: messageContext.chatId,
summaryTitle: dbData.summary_title,
summaryMessage: dbData.summary_message,
});
}
} else {
logger.log('Slack notification not sent', {
messageId: payload.messageId,

View File

@ -18,10 +18,10 @@ import {
ThinkAndPrepOutputSchema,
} from '../utils/memory/types';
import {
isRetryWithHealingError,
createRetryOnErrorHandler,
createUserFriendlyErrorMessage,
handleRetryWithHealing,
isRetryWithHealingError,
} from '../utils/retry';
import type { RetryableError, WorkflowContext } from '../utils/retry/types';
import { createOnChunkHandler, handleStreamingError } from '../utils/streaming';
@ -376,7 +376,7 @@ const analystExecution = async ({
maxRetries,
workflowContext: {
currentStep: 'analyst',
availableTools
availableTools,
},
}),
});
@ -428,15 +428,10 @@ const analystExecution = async ({
// Handle the retry with healing
const { healedMessages, shouldContinueWithoutHealing, backoffDelay } =
await handleRetryWithHealing(
retryableError,
currentMessages,
retryCount,
{
await handleRetryWithHealing(retryableError, currentMessages, retryCount, {
currentStep: 'analyst',
availableTools
}
);
availableTools,
});
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
@ -460,7 +455,7 @@ const analystExecution = async ({
console.error('Analyst: Failed to save healing message to database', {
error: dbError,
retryCount,
willContinueAnyway: true
willContinueAnyway: true,
});
// Continue with retry even if save fails
}

View File

@ -8,10 +8,10 @@ import { thinkAndPrepAgent } from '../agents/think-and-prep-agent/think-and-prep
import type { thinkAndPrepWorkflowInputSchema } from '../schemas/workflow-schemas';
import { ChunkProcessor } from '../utils/database/chunk-processor';
import {
isRetryWithHealingError,
createRetryOnErrorHandler,
createUserFriendlyErrorMessage,
handleRetryWithHealing,
isRetryWithHealingError,
} from '../utils/retry';
import type { RetryableError, WorkflowContext } from '../utils/retry/types';
import { appendToConversation, standardizeMessages } from '../utils/standardizeMessages';
@ -255,7 +255,7 @@ const thinkAndPrepExecution = async ({
maxRetries,
workflowContext: {
currentStep: 'think-and-prep',
availableTools
availableTools,
},
}),
});
@ -302,15 +302,10 @@ const thinkAndPrepExecution = async ({
// Handle the retry with healing
const { healedMessages, shouldContinueWithoutHealing, backoffDelay } =
await handleRetryWithHealing(
retryableError,
currentMessages,
retryCount,
{
await handleRetryWithHealing(retryableError, currentMessages, retryCount, {
currentStep: 'think-and-prep',
availableTools
}
);
availableTools,
});
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
@ -334,7 +329,7 @@ const thinkAndPrepExecution = async ({
console.error('Think and Prep: Failed to save healing message to database', {
error: dbError,
retryCount,
willContinueAnyway: true
willContinueAnyway: true,
});
// Continue with retry even if save fails
}

View File

@ -1,12 +1,12 @@
import type { CoreMessage } from 'ai';
import {
applyHealingStrategy,
determineHealingStrategy,
shouldRetryWithoutHealing,
} from './healing-strategies';
import { detectRetryableError } from './retry-agent-stream';
import { RetryWithHealingError } from './retry-error';
import type { RetryableError, WorkflowContext } from './types';
import {
determineHealingStrategy,
applyHealingStrategy,
shouldRetryWithoutHealing
} from './healing-strategies';
/**
* Creates an onError handler for agent streaming with retry logic
@ -330,7 +330,8 @@ export async function handleRetryWithHealing(
// For network/server errors, just retry without healing
if (shouldRetryWithoutHealing(retryableError.type)) {
const backoffDelay = calculateBackoffDelay(retryCount, 10000) * (healingStrategy.backoffMultiplier || 1);
const backoffDelay =
calculateBackoffDelay(retryCount, 10000) * (healingStrategy.backoffMultiplier || 1);
console.info(`${context.currentStep}: Retrying after network/server error`, {
retryCount,
errorType: retryableError.type,

View File

@ -19,13 +19,13 @@ import {
UnsupportedFunctionalityError,
} from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { detectRetryableError } from '../../../src/utils/retry/retry-agent-stream';
import { RetryWithHealingError } from '../../../src/utils/retry/retry-error';
import {
createRetryOnErrorHandler,
createUserFriendlyErrorMessage,
extractDetailedErrorMessage,
} from '../../../src/utils/retry/retry-helpers';
import { detectRetryableError } from '../../../src/utils/retry/retry-agent-stream';
import { RetryWithHealingError } from '../../../src/utils/retry/retry-error';
import type { WorkflowContext } from '../../../src/utils/retry/types';
describe('AI SDK Error Mocks - Comprehensive Error Handling', () => {
@ -150,9 +150,7 @@ describe('AI SDK Error Mocks - Comprehensive Error Handling', () => {
toolCallId: 'call_123',
args: { query: 123 }, // Wrong type
cause: {
errors: [
{ path: ['query'], message: 'Expected string, received number' },
],
errors: [{ path: ['query'], message: 'Expected string, received number' }],
},
});
@ -439,19 +437,25 @@ describe('AI SDK Error Mocks - Comprehensive Error Handling', () => {
it('should create user-friendly message for API errors', () => {
const error = new Error('API request failed');
const message = createUserFriendlyErrorMessage(error);
expect(message).toBe('The analysis service is temporarily unavailable. Please try again in a few moments.');
expect(message).toBe(
'The analysis service is temporarily unavailable. Please try again in a few moments.'
);
});
it('should create user-friendly message for model errors', () => {
const error = new Error('model not found');
const message = createUserFriendlyErrorMessage(error);
expect(message).toBe('The analysis service is temporarily unavailable. Please try again in a few moments.');
expect(message).toBe(
'The analysis service is temporarily unavailable. Please try again in a few moments.'
);
});
it('should create generic message for unknown errors', () => {
const error = new Error('Something went wrong');
const message = createUserFriendlyErrorMessage(error);
expect(message).toBe('Something went wrong during the analysis. Please try again or contact support if the issue persists.');
expect(message).toBe(
'Something went wrong during the analysis. Please try again or contact support if the issue persists.'
);
});
});

View File

@ -4,17 +4,17 @@ import {
EmptyResponseBodyError,
InvalidToolArgumentsError,
JSONParseError,
NoSuchToolError
NoSuchToolError,
} from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { createRetryOnErrorHandler } from '../../../src/utils/retry/retry-helpers';
import { detectRetryableError } from '../../../src/utils/retry/retry-agent-stream';
import { RetryWithHealingError } from '../../../src/utils/retry/retry-error';
import {
applyHealingStrategy,
determineHealingStrategy,
shouldRetryWithoutHealing,
} from '../../../src/utils/retry/healing-strategies';
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';
import type { WorkflowContext } from '../../../src/utils/retry/types';
describe('Healing Behavior - Different Error Types', () => {
@ -43,7 +43,9 @@ describe('Healing Behavior - Different Error Types', () => {
expect(healedMessages[1]?.content).toBe('Please continue with your analysis.');
// The empty assistant message should be gone
expect(healedMessages.find(m => m.role === 'assistant' && m.content === '')).toBeUndefined();
expect(
healedMessages.find((m) => m.role === 'assistant' && m.content === '')
).toBeUndefined();
});
});
@ -59,9 +61,9 @@ describe('Healing Behavior - Different Error Types', () => {
type: 'tool-call',
toolCallId: '123',
toolName: 'createMetrics',
args: '{"name": "revenue", "expression": ' // Incomplete JSON
}
]
args: '{"name": "revenue", "expression": ', // Incomplete JSON
},
],
},
];
@ -82,7 +84,7 @@ describe('Healing Behavior - Different Error Types', () => {
expect(healedMessages[1]?.content).toBe('Please continue with your analysis.');
// The malformed assistant message should be removed
expect(healedMessages.find(m => m.role === 'assistant')).toBeUndefined();
expect(healedMessages.find((m) => m.role === 'assistant')).toBeUndefined();
});
});
@ -97,9 +99,9 @@ describe('Healing Behavior - Different Error Types', () => {
type: 'tool-call',
toolCallId: 'call_123',
toolName: 'createDashboards',
args: { name: 'Revenue Dashboard' }
}
]
args: { name: 'Revenue Dashboard' },
},
],
},
];
@ -126,7 +128,9 @@ describe('Healing Behavior - Different Error Types', () => {
// Healing message should be added
expect(healedMessages[2]?.role).toBe('tool');
expect(healedMessages[2]?.content[0].type).toBe('tool-result');
expect(healedMessages[2]?.content[0].result.error).toContain('Tool "createDashboards" is not available');
expect(healedMessages[2]?.content[0].result.error).toContain(
'Tool "createDashboards" is not available'
);
});
});
@ -141,9 +145,9 @@ describe('Healing Behavior - Different Error Types', () => {
type: 'tool-call',
toolCallId: 'call_456',
toolName: 'executeSql',
args: { query: 123 } // Wrong type
}
]
args: { query: 123 }, // Wrong type
},
],
},
];
@ -152,9 +156,7 @@ describe('Healing Behavior - Different Error Types', () => {
toolCallId: 'call_456',
args: { query: 123 },
cause: {
errors: [
{ path: ['query'], message: 'Expected string, received number' }
],
errors: [{ path: ['query'], message: 'Expected string, received number' }],
},
});
(error as any).name = 'AI_InvalidToolArgumentsError';
@ -172,7 +174,9 @@ describe('Healing Behavior - Different Error Types', () => {
// Tool error result should be added
const toolResult = healedMessages[2];
expect(toolResult?.role).toBe('tool');
expect(toolResult?.content[0].result.error).toContain('query: Expected string, received number');
expect(toolResult?.content[0].result.error).toContain(
'query: Expected string, received number'
);
});
});

View File

@ -108,13 +108,17 @@ describe('healing-strategies', () => {
it('should remove assistant message and subsequent tool results', () => {
const messages: CoreMessage[] = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: [
{
role: 'assistant',
content: [
{ type: 'text', text: 'Let me help' },
{ type: 'tool-call', toolCallId: '123', toolName: 'test', args: {} },
]},
{ role: 'tool', content: [
{ type: 'tool-result', toolCallId: '123', toolName: 'test', result: {} },
]},
],
},
{
role: 'tool',
content: [{ type: 'tool-result', toolCallId: '123', toolName: 'test', result: {} }],
},
];
const result = removeLastAssistantMessage(messages);
@ -158,7 +162,10 @@ describe('healing-strategies', () => {
const strategy = {
shouldRemoveLastAssistantMessage: true,
healingMessage: { role: 'user', content: 'Please continue with your analysis.' } as CoreMessage,
healingMessage: {
role: 'user',
content: 'Please continue with your analysis.',
} as CoreMessage,
};
const result = applyHealingStrategy(messages, strategy);
@ -171,9 +178,10 @@ describe('healing-strategies', () => {
it('should add healing without removing for tool errors', () => {
const messages: CoreMessage[] = [
{ role: 'user', content: 'Analyze data' },
{ role: 'assistant', content: [
{ type: 'tool-call', toolCallId: '123', toolName: 'wrongTool', args: {} },
]},
{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: '123', toolName: 'wrongTool', args: {} }],
},
];
const strategy = {
@ -237,7 +245,7 @@ describe('healing-strategies', () => {
healingMessage: { role: 'user', content: '' },
};
expect(getErrorExplanationForUser(emptyError)).toBe(
'The assistant\'s response was incomplete. Retrying...'
"The assistant's response was incomplete. Retrying..."
);
const jsonError: RetryableError = {
@ -255,7 +263,7 @@ describe('healing-strategies', () => {
healingMessage: { role: 'user', content: '' },
};
expect(getErrorExplanationForUser(toolError)).toBe(
'The assistant tried to use a tool that\'s not available in the current mode.'
"The assistant tried to use a tool that's not available in the current mode."
);
});

View File

@ -1,16 +1,16 @@
import { NoSuchToolError } from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { RetryWithHealingError } from '../../../src/utils/retry/retry-error';
import {
calculateBackoffDelay,
createRetryOnErrorHandler,
createUserFriendlyErrorMessage,
extractDetailedErrorMessage,
findHealingMessageInsertionIndex,
handleRetryWithHealing,
logMessagesAfterHealing,
logRetryInfo,
handleRetryWithHealing,
} from '../../../src/utils/retry/retry-helpers';
import { RetryWithHealingError } from '../../../src/utils/retry/retry-error';
import type { CoreMessage, RetryableError } from '../../../src/utils/retry/types';
// Mock the detectRetryableError function
@ -133,7 +133,9 @@ describe('retry-helpers', () => {
};
const result = extractDetailedErrorMessage(error);
expect(result).toBe('Validation failed - Validation errors: field.nested: Required; other: Invalid');
expect(result).toBe(
'Validation failed - Validation errors: field.nested: Required; other: Invalid'
);
});
it('should include status code for API errors', () => {
@ -183,7 +185,9 @@ describe('retry-helpers', () => {
(error as any).responseBody = 'Server error details';
const result = extractDetailedErrorMessage(error);
expect(result).toBe('Complex error (Status: 500) - Response: Server error details (Tool: complexTool)');
expect(result).toBe(
'Complex error (Status: 500) - Response: Server error details (Tool: complexTool)'
);
});
});
@ -461,12 +465,9 @@ describe('retry-helpers', () => {
{ role: 'assistant', content: 'Processing...' },
];
const result = await handleRetryWithHealing(
retryableError,
messages,
2,
{ currentStep: 'analyst' }
);
const result = await handleRetryWithHealing(retryableError, messages, 2, {
currentStep: 'analyst',
});
expect(result.shouldContinueWithoutHealing).toBe(true);
expect(result.healedMessages).toEqual(messages); // Messages unchanged
@ -489,12 +490,9 @@ describe('retry-helpers', () => {
{ role: 'assistant', content: '' }, // Empty response
];
const result = await handleRetryWithHealing(
retryableError,
messages,
1,
{ currentStep: 'think-and-prep' }
);
const result = await handleRetryWithHealing(retryableError, messages, 1, {
currentStep: 'think-and-prep',
});
expect(result.shouldContinueWithoutHealing).toBe(false);
expect(result.healedMessages).toHaveLength(2);
@ -542,12 +540,9 @@ describe('retry-helpers', () => {
},
];
const result = await handleRetryWithHealing(
retryableError,
messages,
0,
{ currentStep: 'analyst' }
);
const result = await handleRetryWithHealing(retryableError, messages, 0, {
currentStep: 'analyst',
});
expect(result.shouldContinueWithoutHealing).toBe(false);
expect(result.healedMessages).toHaveLength(3);
@ -567,16 +562,11 @@ describe('retry-helpers', () => {
originalError: new Error('429 Too Many Requests'),
};
const messages: CoreMessage[] = [
{ role: 'user', content: 'Query' },
];
const messages: CoreMessage[] = [{ role: 'user', content: 'Query' }];
const result = await handleRetryWithHealing(
retryableError,
messages,
3,
{ currentStep: 'analyst' }
);
const result = await handleRetryWithHealing(retryableError, messages, 3, {
currentStep: 'analyst',
});
expect(result.shouldContinueWithoutHealing).toBe(true);
expect(result.backoffDelay).toBe(24000); // 2^3 * 1000 * 3 (rate limit multiplier)

View File

@ -3,8 +3,8 @@ import { RuntimeContext } from '@mastra/core/runtime-context';
import type { CoreMessage, StreamTextResult, TextStreamPart } from 'ai';
import { APICallError, NoSuchToolError } from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { createRetryOnErrorHandler } from '../../../src/utils/retry/retry-helpers';
import { RetryWithHealingError } from '../../../src/utils/retry/retry-error';
import { createRetryOnErrorHandler } from '../../../src/utils/retry/retry-helpers';
import type { WorkflowContext } from '../../../src/utils/retry/types';
describe('Streaming Error Scenarios - Real-World Tests', () => {
@ -35,9 +35,7 @@ describe('Streaming Error Scenarios - Real-World Tests', () => {
describe('Stream Error During Tool Call', () => {
it('should handle error thrown during tool call streaming', async () => {
const messages: CoreMessage[] = [
{ role: 'user', content: 'Analyze my data' },
];
const messages: CoreMessage[] = [{ role: 'user', content: 'Analyze my data' }];
let onChunkCalled = 0;
let onErrorHandler: ((event: { error: unknown }) => Promise<void>) | undefined;
@ -253,13 +251,14 @@ describe('Streaming Error Scenarios - Real-World Tests', () => {
describe('Complex Streaming Scenarios', () => {
it('should handle partial tool call followed by error', async () => {
const messages: CoreMessage[] = [
{ role: 'user', content: 'Create a metric for revenue' },
];
const messages: CoreMessage[] = [{ role: 'user', content: 'Create a metric for revenue' }];
async function* streamGenerator() {
// Start with text
yield { type: 'text-delta', textDelta: 'I\'ll create a revenue metric' } as TextStreamPart<any>;
yield {
type: 'text-delta',
textDelta: "I'll create a revenue metric",
} as TextStreamPart<any>;
// Start tool call
yield {
@ -298,7 +297,9 @@ describe('Streaming Error Scenarios - Real-World Tests', () => {
expect(errorThrown).toBeInstanceOf(RetryWithHealingError);
expect(errorThrown.retryableError.type).toBe('unknown-error');
expect(errorThrown.retryableError.healingMessage.content).toContain('Unexpected end of JSON input');
expect(errorThrown.retryableError.healingMessage.content).toContain(
'Unexpected end of JSON input'
);
consoleErrorSpy.mockRestore();
consoleInfoSpy.mockRestore();