buster/packages/ai/tests/steps/unit/analyst-step-file-selection...

834 lines
27 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import type {
ChatMessageReasoningMessage,
ChatMessageResponseMessage,
} from '../../../../../server/src/types/chat-types/chat-message.type';
import { hasFailureIndicators, hasFileFailureIndicators } from '../../../src/utils/database/types';
// Import the functions we want to test (we'll need to export them from analyst-step.ts)
// For now, I'll copy them here for testing
interface ExtractedFile {
id: string;
fileType: 'metric' | 'dashboard';
fileName: string;
status: 'completed' | 'failed' | 'loading';
ymlContent?: string;
}
function extractFilesFromReasoning(
reasoningHistory: ChatMessageReasoningMessage[]
): ExtractedFile[] {
const files: ExtractedFile[] = [];
for (const entry of reasoningHistory) {
// Multi-layer safety checks:
// 1. Must be a files entry with completed status
// 2. Must not have any failure indicators (additional safety net)
// 3. Individual files must have completed status
if (
entry.type === 'files' &&
entry.status === 'completed' &&
entry.files &&
!hasFailureIndicators(entry)
) {
for (const fileId of entry.file_ids || []) {
const file = entry.files[fileId];
// Enhanced file validation:
// - File must exist and have completed status
// - File must not have error indicators
// - File must have required properties (file_type, file_name)
if (
file &&
file.status === 'completed' &&
file.file_type &&
file.file_name &&
!hasFileFailureIndicators(file)
) {
files.push({
id: fileId,
fileType: file.file_type as 'metric' | 'dashboard',
fileName: file.file_name,
status: 'completed',
ymlContent: file.file?.text,
});
}
}
}
}
return files;
}
function selectFilesForResponse(files: ExtractedFile[]): ExtractedFile[] {
// Separate dashboards and metrics
const dashboards = files.filter((f) => f.fileType === 'dashboard');
const metrics = files.filter((f) => f.fileType === 'metric');
// Apply priority logic
if (dashboards.length > 0) {
return dashboards; // Return all dashboards
}
if (metrics.length > 0) {
return metrics; // Return all metrics
}
return []; // No files to return
}
function createFileResponseMessages(files: ExtractedFile[]): ChatMessageResponseMessage[] {
return files.map((file) => ({
id: crypto.randomUUID(),
type: 'file' as const,
file_type: file.fileType,
file_name: file.fileName,
version_number: 1,
filter_version_id: null,
metadata: [
{
status: 'completed' as const,
message: `${file.fileType === 'dashboard' ? 'Dashboard' : 'Metric'} created successfully`,
timestamp: Date.now(),
},
],
}));
}
describe('Analyst Step File Selection', () => {
describe('extractFilesFromReasoning', () => {
test('should extract completed metric files', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'reason-1',
type: 'files',
title: 'Creating 2 metrics',
status: 'completed',
file_ids: ['file-1', 'file-2'],
files: {
'file-1': {
id: 'file-1',
file_type: 'metric',
file_name: 'revenue_metric.yml',
version_number: 1,
status: 'completed',
file: {
text: 'metric: revenue\ntype: sum',
},
},
'file-2': {
id: 'file-2',
file_type: 'metric',
file_name: 'user_count_metric.yml',
version_number: 1,
status: 'completed',
file: {
text: 'metric: user_count\ntype: count',
},
},
},
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
expect(extracted).toHaveLength(2);
expect(extracted[0]).toEqual({
id: 'file-1',
fileType: 'metric',
fileName: 'revenue_metric.yml',
status: 'completed',
ymlContent: 'metric: revenue\ntype: sum',
});
expect(extracted[1]).toEqual({
id: 'file-2',
fileType: 'metric',
fileName: 'user_count_metric.yml',
status: 'completed',
ymlContent: 'metric: user_count\ntype: count',
});
});
test('should extract completed dashboard files', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'reason-1',
type: 'files',
title: 'Creating 1 dashboard',
status: 'completed',
file_ids: ['dash-1'],
files: {
'dash-1': {
id: 'dash-1',
file_type: 'dashboard',
file_name: 'sales_dashboard.yml',
version_number: 1,
status: 'completed',
file: {
text: 'dashboard: sales\nmetrics: [revenue, user_count]',
},
},
},
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
expect(extracted).toHaveLength(1);
expect(extracted[0]).toEqual({
id: 'dash-1',
fileType: 'dashboard',
fileName: 'sales_dashboard.yml',
status: 'completed',
ymlContent: 'dashboard: sales\nmetrics: [revenue, user_count]',
});
});
test('should skip files with loading or failed status', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'reason-1',
type: 'files',
title: 'Creating metrics',
status: 'completed', // Entry is completed but individual files may not be
file_ids: ['file-1', 'file-2', 'file-3'],
files: {
'file-1': {
id: 'file-1',
file_type: 'metric',
file_name: 'metric1.yml',
version_number: 1,
status: 'completed',
file: { text: 'completed metric' },
},
'file-2': {
id: 'file-2',
file_type: 'metric',
file_name: 'metric2.yml',
version_number: 1,
status: 'loading',
file: { text: 'loading metric' },
},
'file-3': {
id: 'file-3',
file_type: 'metric',
file_name: 'metric3.yml',
version_number: 1,
status: 'failed',
file: { text: 'failed metric' },
},
},
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
expect(extracted).toHaveLength(1);
const firstExtracted = extracted[0];
expect(firstExtracted).toBeDefined();
expect(firstExtracted?.id).toBe('file-1');
expect(firstExtracted?.status).toBe('completed');
});
test('should skip reasoning entries with non-completed status', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'reason-1',
type: 'files',
title: 'Creating metrics',
status: 'loading', // Still in progress
file_ids: ['file-1'],
files: {
'file-1': {
id: 'file-1',
file_type: 'metric',
file_name: 'metric.yml',
version_number: 1,
status: 'completed',
file: { text: 'metric content' },
},
},
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
expect(extracted).toHaveLength(0);
});
test('should handle mixed file types from multiple entries', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'reason-1',
type: 'files',
title: 'Creating metrics',
status: 'completed',
file_ids: ['metric-1', 'metric-2'],
files: {
'metric-1': {
id: 'metric-1',
file_type: 'metric',
file_name: 'metric1.yml',
version_number: 1,
status: 'completed',
file: { text: 'metric 1' },
},
'metric-2': {
id: 'metric-2',
file_type: 'metric',
file_name: 'metric2.yml',
version_number: 1,
status: 'completed',
file: { text: 'metric 2' },
},
},
},
{
id: 'reason-2',
type: 'files',
title: 'Creating dashboard',
status: 'completed',
file_ids: ['dash-1'],
files: {
'dash-1': {
id: 'dash-1',
file_type: 'dashboard',
file_name: 'dashboard.yml',
version_number: 1,
status: 'completed',
file: { text: 'dashboard content' },
},
},
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
expect(extracted).toHaveLength(3);
expect(extracted.filter((f) => f.fileType === 'metric')).toHaveLength(2);
expect(extracted.filter((f) => f.fileType === 'dashboard')).toHaveLength(1);
});
test('should handle empty reasoning history', () => {
const extracted = extractFilesFromReasoning([]);
expect(extracted).toHaveLength(0);
});
test('should reject files from entries with error indicators (failure detection)', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'entry-with-error',
type: 'files',
title: 'Creating metrics',
status: 'completed', // Status says completed
error: 'Validation warnings occurred', // But has error field - this should prevent file extraction
file_ids: ['file-1'],
files: {
'file-1': {
id: 'file-1',
file_type: 'metric',
file_name: 'metric.yml',
version_number: 1,
status: 'completed',
file: { text: 'metric: test' },
},
},
} as any,
];
const extracted = extractFilesFromReasoning(reasoningHistory);
// Should extract no files due to error indicator in entry
// With our new logic, entry-level errors should still prevent extraction
expect(extracted).toHaveLength(0);
});
test('should reject individual files with error properties (enhanced validation)', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'entry-clean',
type: 'files',
title: 'Creating metrics',
status: 'completed',
file_ids: ['file-1', 'file-2'],
files: {
'file-1': {
id: 'file-1',
file_type: 'metric',
file_name: 'good_metric.yml',
version_number: 1,
status: 'completed',
file: { text: 'metric: good' },
},
'file-2': {
id: 'file-2',
file_type: 'metric',
file_name: 'problematic_metric.yml',
version_number: 1,
status: 'completed', // Status completed
error: 'Schema validation warning', // But has error property
file: { text: 'metric: problematic' },
} as any,
},
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
// Should only extract the file without error properties
expect(extracted).toHaveLength(1);
const goodFile = extracted[0];
expect(goodFile).toBeDefined();
expect(goodFile?.id).toBe('file-1');
expect(goodFile?.fileName).toBe('good_metric.yml');
});
test('should skip non-file reasoning entries', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'reason-1',
type: 'text',
title: 'Thinking...',
status: 'completed',
message: 'Some thinking process',
},
{
id: 'reason-2',
type: 'pills',
title: 'Analysis',
status: 'completed',
pill_containers: [],
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
expect(extracted).toHaveLength(0);
});
});
describe('selectFilesForResponse', () => {
test('should return all dashboards when dashboards exist', () => {
const files: ExtractedFile[] = [
{ id: 'dash-1', fileType: 'dashboard', fileName: 'dash1.yml', status: 'completed' },
{ id: 'dash-2', fileType: 'dashboard', fileName: 'dash2.yml', status: 'completed' },
{ id: 'metric-1', fileType: 'metric', fileName: 'metric1.yml', status: 'completed' },
{ id: 'metric-2', fileType: 'metric', fileName: 'metric2.yml', status: 'completed' },
];
const selected = selectFilesForResponse(files);
expect(selected).toHaveLength(2);
expect(selected.every((f) => f.fileType === 'dashboard')).toBe(true);
expect(selected.map((f) => f.id)).toEqual(['dash-1', 'dash-2']);
});
test('should return all metrics when only metrics exist', () => {
const files: ExtractedFile[] = [
{ id: 'metric-1', fileType: 'metric', fileName: 'metric1.yml', status: 'completed' },
{ id: 'metric-2', fileType: 'metric', fileName: 'metric2.yml', status: 'completed' },
{ id: 'metric-3', fileType: 'metric', fileName: 'metric3.yml', status: 'completed' },
];
const selected = selectFilesForResponse(files);
expect(selected).toHaveLength(3);
expect(selected.every((f) => f.fileType === 'metric')).toBe(true);
expect(selected.map((f) => f.id)).toEqual(['metric-1', 'metric-2', 'metric-3']);
});
test('should return single metric when only one metric exists', () => {
const files: ExtractedFile[] = [
{ id: 'metric-1', fileType: 'metric', fileName: 'metric1.yml', status: 'completed' },
];
const selected = selectFilesForResponse(files);
expect(selected).toHaveLength(1);
const firstSelected = selected[0];
expect(firstSelected).toBeDefined();
expect(firstSelected?.id).toBe('metric-1');
expect(firstSelected?.fileType).toBe('metric');
});
test('should return single dashboard when only one dashboard exists', () => {
const files: ExtractedFile[] = [
{ id: 'dash-1', fileType: 'dashboard', fileName: 'dash1.yml', status: 'completed' },
];
const selected = selectFilesForResponse(files);
expect(selected).toHaveLength(1);
const firstSelected = selected[0];
expect(firstSelected).toBeDefined();
expect(firstSelected?.id).toBe('dash-1');
expect(firstSelected?.fileType).toBe('dashboard');
});
test('should return empty array when no files exist', () => {
const selected = selectFilesForResponse([]);
expect(selected).toHaveLength(0);
});
test('should prioritize single dashboard over multiple metrics', () => {
const files: ExtractedFile[] = [
{ id: 'metric-1', fileType: 'metric', fileName: 'metric1.yml', status: 'completed' },
{ id: 'metric-2', fileType: 'metric', fileName: 'metric2.yml', status: 'completed' },
{ id: 'metric-3', fileType: 'metric', fileName: 'metric3.yml', status: 'completed' },
{ id: 'dash-1', fileType: 'dashboard', fileName: 'dash1.yml', status: 'completed' },
];
const selected = selectFilesForResponse(files);
expect(selected).toHaveLength(1);
const firstSelected = selected[0];
expect(firstSelected).toBeDefined();
expect(firstSelected?.id).toBe('dash-1');
expect(firstSelected?.fileType).toBe('dashboard');
});
});
describe('createFileResponseMessages', () => {
test('should create response messages for metric files', () => {
const files: ExtractedFile[] = [
{ id: 'metric-1', fileType: 'metric', fileName: 'revenue.yml', status: 'completed' },
{ id: 'metric-2', fileType: 'metric', fileName: 'users.yml', status: 'completed' },
];
const messages = createFileResponseMessages(files);
expect(messages).toHaveLength(2);
const firstMessage = messages[0];
expect(firstMessage).toBeDefined();
expect(firstMessage).toMatchObject({
type: 'file',
file_type: 'metric',
file_name: 'revenue.yml',
version_number: 1,
filter_version_id: null,
});
if (firstMessage && firstMessage.type === 'file' && firstMessage.metadata) {
const firstMetadata = firstMessage.metadata[0];
expect(firstMetadata).toBeDefined();
expect(firstMetadata).toMatchObject({
status: 'completed',
message: 'Metric created successfully',
});
expect(firstMetadata?.timestamp).toBeTypeOf('number');
}
expect(messages[1]).toMatchObject({
type: 'file',
file_type: 'metric',
file_name: 'users.yml',
version_number: 1,
});
});
test('should create response messages for dashboard files', () => {
const files: ExtractedFile[] = [
{
id: 'dash-1',
fileType: 'dashboard',
fileName: 'sales_dashboard.yml',
status: 'completed',
},
];
const messages = createFileResponseMessages(files);
expect(messages).toHaveLength(1);
const firstMessage = messages[0];
expect(firstMessage).toBeDefined();
expect(firstMessage).toMatchObject({
type: 'file',
file_type: 'dashboard',
file_name: 'sales_dashboard.yml',
version_number: 1,
filter_version_id: null,
});
if (firstMessage && firstMessage.type === 'file' && firstMessage.metadata) {
const firstMetadata = firstMessage.metadata[0];
expect(firstMetadata).toBeDefined();
expect(firstMetadata).toMatchObject({
status: 'completed',
message: 'Dashboard created successfully',
});
}
});
test('should generate unique IDs for each message', () => {
const files: ExtractedFile[] = [
{ id: 'file-1', fileType: 'metric', fileName: 'metric1.yml', status: 'completed' },
{ id: 'file-2', fileType: 'metric', fileName: 'metric2.yml', status: 'completed' },
];
const messages = createFileResponseMessages(files);
const firstMessage = messages[0];
const secondMessage = messages[1];
expect(firstMessage).toBeDefined();
expect(secondMessage).toBeDefined();
expect(firstMessage?.id).toBeTypeOf('string');
expect(secondMessage?.id).toBeTypeOf('string');
expect(firstMessage?.id).not.toBe(secondMessage?.id);
});
test('should handle empty file array', () => {
const messages = createFileResponseMessages([]);
expect(messages).toHaveLength(0);
});
});
describe('Integration: Complete File Selection Flow', () => {
test('Scenario 1: Only dashboards created - return all dashboards', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'reason-1',
type: 'files',
title: 'Creating dashboards',
status: 'completed',
file_ids: ['dash-1', 'dash-2'],
files: {
'dash-1': {
id: 'dash-1',
file_type: 'dashboard',
file_name: 'sales_dashboard.yml',
version_number: 1,
status: 'completed',
file: { text: 'dashboard 1 content' },
},
'dash-2': {
id: 'dash-2',
file_type: 'dashboard',
file_name: 'marketing_dashboard.yml',
version_number: 1,
status: 'completed',
file: { text: 'dashboard 2 content' },
},
},
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
const selected = selectFilesForResponse(extracted);
const messages = createFileResponseMessages(selected);
expect(extracted).toHaveLength(2);
expect(selected).toHaveLength(2);
expect(messages).toHaveLength(2);
expect(messages.every((m) => m.type === 'file' && m.file_type === 'dashboard')).toBe(true);
});
test('Scenario 2: Multiple metrics created - return all metrics', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'reason-1',
type: 'files',
title: 'Creating metrics',
status: 'completed',
file_ids: ['metric-1', 'metric-2', 'metric-3'],
files: {
'metric-1': {
id: 'metric-1',
file_type: 'metric',
file_name: 'revenue.yml',
version_number: 1,
status: 'completed',
file: { text: 'metric 1' },
},
'metric-2': {
id: 'metric-2',
file_type: 'metric',
file_name: 'users.yml',
version_number: 1,
status: 'completed',
file: { text: 'metric 2' },
},
'metric-3': {
id: 'metric-3',
file_type: 'metric',
file_name: 'growth.yml',
version_number: 1,
status: 'completed',
file: { text: 'metric 3' },
},
},
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
const selected = selectFilesForResponse(extracted);
const messages = createFileResponseMessages(selected);
expect(extracted).toHaveLength(3);
expect(selected).toHaveLength(3);
expect(messages).toHaveLength(3);
expect(messages.every((m) => m.type === 'file' && m.file_type === 'metric')).toBe(true);
});
test('Scenario 3: Single metric created - return the single metric', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'reason-1',
type: 'files',
title: 'Creating metric',
status: 'completed',
file_ids: ['metric-1'],
files: {
'metric-1': {
id: 'metric-1',
file_type: 'metric',
file_name: 'revenue.yml',
version_number: 1,
status: 'completed',
file: { text: 'single metric' },
},
},
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
const selected = selectFilesForResponse(extracted);
const messages = createFileResponseMessages(selected);
expect(extracted).toHaveLength(1);
expect(selected).toHaveLength(1);
expect(messages).toHaveLength(1);
const firstMessage = messages[0];
expect(firstMessage).toBeDefined();
expect(firstMessage?.type).toBe('file');
if (firstMessage && firstMessage.type === 'file') {
expect(firstMessage.file_type).toBe('metric');
}
});
test('Scenario 4: Metrics and dashboards created - return only dashboards', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'reason-1',
type: 'files',
title: 'Creating metrics',
status: 'completed',
file_ids: ['metric-1', 'metric-2'],
files: {
'metric-1': {
id: 'metric-1',
file_type: 'metric',
file_name: 'revenue.yml',
version_number: 1,
status: 'completed',
file: { text: 'metric 1' },
},
'metric-2': {
id: 'metric-2',
file_type: 'metric',
file_name: 'users.yml',
version_number: 1,
status: 'completed',
file: { text: 'metric 2' },
},
},
},
{
id: 'reason-2',
type: 'files',
title: 'Creating dashboards',
status: 'completed',
file_ids: ['dash-1', 'dash-2'],
files: {
'dash-1': {
id: 'dash-1',
file_type: 'dashboard',
file_name: 'sales_dashboard.yml',
version_number: 1,
status: 'completed',
file: { text: 'dashboard 1' },
},
'dash-2': {
id: 'dash-2',
file_type: 'dashboard',
file_name: 'marketing_dashboard.yml',
version_number: 1,
status: 'completed',
file: { text: 'dashboard 2' },
},
},
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
const selected = selectFilesForResponse(extracted);
const messages = createFileResponseMessages(selected);
expect(extracted).toHaveLength(4); // 2 metrics + 2 dashboards
expect(selected).toHaveLength(2); // Only dashboards
expect(messages).toHaveLength(2);
expect(messages.every((m) => m.type === 'file' && m.file_type === 'dashboard')).toBe(true);
expect(messages.map((m) => (m.type === 'file' ? m.file_name : ''))).toEqual([
'sales_dashboard.yml',
'marketing_dashboard.yml',
]);
});
test('Scenario 5: Modified files are also included', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'reason-1',
type: 'files',
title: 'Modifying metrics',
status: 'completed',
file_ids: ['metric-1-mod', 'metric-2-mod'],
files: {
'metric-1-mod': {
id: 'metric-1-mod',
file_type: 'metric',
file_name: 'revenue_modified.yml',
version_number: 2,
status: 'completed',
file: { text: 'modified metric 1' },
},
'metric-2-mod': {
id: 'metric-2-mod',
file_type: 'metric',
file_name: 'users_modified.yml',
version_number: 2,
status: 'completed',
file: { text: 'modified metric 2' },
},
},
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
const selected = selectFilesForResponse(extracted);
const messages = createFileResponseMessages(selected);
expect(extracted).toHaveLength(2);
expect(selected).toHaveLength(2);
expect(messages).toHaveLength(2);
expect(messages.every((m) => m.type === 'file' && m.file_type === 'metric')).toBe(true);
});
test('Scenario 6: No files created - return empty', () => {
const reasoningHistory: ChatMessageReasoningMessage[] = [
{
id: 'reason-1',
type: 'text',
title: 'Thinking',
status: 'completed',
message: 'Just thinking, no files created',
},
];
const extracted = extractFilesFromReasoning(reasoningHistory);
const selected = selectFilesForResponse(extracted);
const messages = createFileResponseMessages(selected);
expect(extracted).toHaveLength(0);
expect(selected).toHaveLength(0);
expect(messages).toHaveLength(0);
});
});
});