import type { CoreMessage, TextStreamPart, ToolSet } from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { ChunkProcessor } from '../../../src/utils/database/chunk-processor';
// Mock the database update function
vi.mock('@buster/database', () => ({
updateMessageFields: vi.fn().mockResolvedValue(undefined),
}));
describe('ChunkProcessor - Duplicate Message Detection', () => {
const mockMessageId = 'test-message-id';
it('should not create duplicate assistant messages with same toolCallId', async () => {
const processor = new ChunkProcessor(mockMessageId, [], [], []);
// Set initial messages (user prompt + todo list)
const initialMessages: CoreMessage[] = [
{
role: 'user',
content: 'of our accessory products, what are the top 5 by revenue this month?',
},
{
role: 'user',
content: [
{
text: '\n - Below are the items on your TODO list:\n [ ] Determine how "accessory products" are identified in the data\n[ ] Determine how "revenue" is calculated for products\n[ ] Determine how to filter by "this month"\n[ ] Determine sorting and limit for selecting the top 5 products\n[ ] Determine the visualization type and axes\n ',
type: 'text',
},
],
},
];
processor.setInitialMessages(initialMessages);
expect(processor.getLastProcessedIndex()).toBe(1); // Last initial message
// Simulate streaming of sequential thinking tool call
const toolCallId = 'toolu_01Mvn2dPmMEDzYbsM98pPtaC';
// First, tool-call-streaming-start
await processor.processChunk({
type: 'tool-call-streaming-start',
toolCallId,
toolName: 'sequentialThinking',
} as TextStreamPart);
// Then tool-call-delta events (simulating streaming args)
await processor.processChunk({
type: 'tool-call-delta',
toolCallId,
argsTextDelta: '{"thought": "Let me work through',
} as TextStreamPart);
await processor.processChunk({
type: 'tool-call-delta',
toolCallId,
argsTextDelta: ' the TODO list items..."}',
} as TextStreamPart);
// Then the complete tool-call event
await processor.processChunk({
type: 'tool-call',
toolCallId,
toolName: 'sequentialThinking',
args: {
thought: 'Let me work through the TODO list items...',
isRevision: false,
thoughtNumber: 1,
totalThoughts: 3,
needsMoreThoughts: false,
nextThoughtNeeded: true,
},
} as TextStreamPart);
// Tool result
await processor.processChunk({
type: 'tool-result',
toolCallId,
toolName: 'sequentialThinking',
result: { success: true },
} as TextStreamPart);
// Get accumulated messages
const messages = processor.getAccumulatedMessages();
// Count assistant messages with the same toolCallId
const assistantMessagesWithToolCall = messages.filter((msg) => {
if (msg.role !== 'assistant' || !Array.isArray(msg.content)) return false;
return msg.content.some(
(item) => item.type === 'tool-call' && item.toolCallId === toolCallId
);
});
// Should only have ONE assistant message with this toolCallId
expect(assistantMessagesWithToolCall).toHaveLength(1);
// Verify the structure
expect(messages).toHaveLength(4); // 2 initial + 1 assistant + 1 tool result
expect(messages[0]).toEqual(initialMessages[0]);
expect(messages[1]).toEqual(initialMessages[1]);
expect(messages[2].role).toBe('assistant');
expect(messages[3].role).toBe('tool');
// Verify the assistant message has the tool call
const assistantMsg = messages[2];
expect(assistantMsg.content).toEqual([
{
type: 'tool-call',
toolCallId,
toolName: 'sequentialThinking',
args: {
thought: 'Let me work through the TODO list items...',
isRevision: false,
thoughtNumber: 1,
totalThoughts: 3,
needsMoreThoughts: false,
nextThoughtNeeded: true,
},
},
]);
});
it('should handle multiple streaming tool calls without duplicates', async () => {
const processor = new ChunkProcessor(mockMessageId, [], [], []);
// Process first tool call
const toolCallId1 = 'toolu_01ABC';
await processor.processChunk({
type: 'tool-call-streaming-start',
toolCallId: toolCallId1,
toolName: 'executeSql',
} as TextStreamPart);
await processor.processChunk({
type: 'tool-call',
toolCallId: toolCallId1,
toolName: 'executeSql',
args: { statements: ['SELECT 1'] },
} as TextStreamPart);
await processor.processChunk({
type: 'tool-result',
toolCallId: toolCallId1,
toolName: 'executeSql',
result: { results: [] },
} as TextStreamPart);
// Process second tool call
const toolCallId2 = 'toolu_02DEF';
await processor.processChunk({
type: 'tool-call-streaming-start',
toolCallId: toolCallId2,
toolName: 'executeSql',
} as TextStreamPart);
await processor.processChunk({
type: 'tool-call',
toolCallId: toolCallId2,
toolName: 'executeSql',
args: { statements: ['SELECT 2'] },
} as TextStreamPart);
await processor.processChunk({
type: 'tool-result',
toolCallId: toolCallId2,
toolName: 'executeSql',
result: { results: [] },
} as TextStreamPart);
const messages = processor.getAccumulatedMessages();
// Should have 4 messages: 2 assistant + 2 tool results
expect(messages).toHaveLength(4);
// Each tool call should appear exactly once
const toolCallIds = messages
.filter((m) => m.role === 'assistant')
.flatMap((m) => (Array.isArray(m.content) ? m.content : []))
.filter((c) => c.type === 'tool-call')
.map((c) => c.toolCallId);
expect(toolCallIds).toEqual([toolCallId1, toolCallId2]);
expect(new Set(toolCallIds).size).toBe(toolCallIds.length); // No duplicates
});
it('should not duplicate messages when same content is processed multiple times', async () => {
const processor = new ChunkProcessor(mockMessageId, [], [], []);
// Simulate a scenario where the same tool call might be processed twice
// (this could happen due to retries or other issues)
const toolCallId = 'toolu_01XYZ';
// First processing
await processor.processChunk({
type: 'tool-call',
toolCallId,
toolName: 'sequentialThinking',
args: { thought: 'Test thought' },
} as TextStreamPart);
// Get messages after first processing
const messagesAfterFirst = processor.getAccumulatedMessages();
expect(messagesAfterFirst).toHaveLength(1);
// Try to process the same tool call again (shouldn't create duplicate)
await processor.processChunk({
type: 'tool-call',
toolCallId,
toolName: 'sequentialThinking',
args: { thought: 'Test thought' },
} as TextStreamPart);
// Should still have only 1 message
const messagesAfterSecond = processor.getAccumulatedMessages();
expect(messagesAfterSecond).toHaveLength(1);
});
});