diff --git a/src/frontend/src/modals/IOModal/components/chatView/__tests__/message-ordering-regression.test.ts b/src/frontend/src/modals/IOModal/components/chatView/__tests__/message-ordering-regression.test.ts new file mode 100644 index 000000000000..96a47fe96261 --- /dev/null +++ b/src/frontend/src/modals/IOModal/components/chatView/__tests__/message-ordering-regression.test.ts @@ -0,0 +1,515 @@ +import type { ChatMessageType } from "../../../../../types/chat"; +import sortSenderMessages from "../helpers/sort-sender-messages"; + +/** + * Regression tests for GitHub issue #9186: Chat history ordering problem + * + * These tests specifically validate the scenarios that were causing + * message ordering issues in production. + */ + +// Helper function to create mock ChatMessageType matching real data structure +const createMessage = ( + id: string, + timestamp: string, + isSend: boolean, + message: string = "test message", +): ChatMessageType => ({ + id, + timestamp, + isSend, + message, + sender_name: isSend ? "User" : "AI", + session: "session-test", + files: [], + edit: false, + category: "message", + properties: { + source: { id: "test", display_name: "Test", source: "test" }, + }, + content_blocks: [], +}); + +describe("Message Ordering Regression Tests - GitHub Issue #9186", () => { + describe("Production scenarios that caused ordering issues", () => { + it("should handle identical timestamps from PostgreSQL with second precision", () => { + // Real scenario: PostgreSQL storing timestamps with second precision + // Frontend generates UTC+0, backend was UTC+3, causing identical second-level timestamps + const messages = [ + createMessage( + "ai-response", + "2025-08-29 08:51:21 UTC", + false, + "I'm an AI language model", + ), + createMessage( + "user-question", + "2025-08-29 08:51:21 UTC", + true, + "Who are you?", + ), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + // User question should come first, even though AI response was added to array first + expect(sorted[0].id).toBe("user-question"); + expect(sorted[0].isSend).toBe(true); + expect(sorted[1].id).toBe("ai-response"); + expect(sorted[1].isSend).toBe(false); + }); + + it("should handle streaming responses with load balancer timing", () => { + // Scenario: Load balancer + streaming causing multiple messages with same timestamp + const messages = [ + createMessage( + "stream-chunk-2", + "2025-08-29 08:51:21 UTC", + false, + "models currently", + ), + createMessage( + "user-input", + "2025-08-29 08:51:21 UTC", + true, + "what models do you support?", + ), + createMessage( + "stream-chunk-1", + "2025-08-29 08:51:21 UTC", + false, + "It seems there are various", + ), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + // User input should be first + expect(sorted[0].id).toBe("user-input"); + expect(sorted[0].isSend).toBe(true); + + // AI streaming chunks should follow, maintaining their relative order + expect(sorted[1].isSend).toBe(false); + expect(sorted[2].isSend).toBe(false); + expect(sorted[1].id).toBe("stream-chunk-2"); // Original order preserved for same type + expect(sorted[2].id).toBe("stream-chunk-1"); + }); + + it("should handle rapid-fire user questions with immediate AI responses", () => { + // Scenario: User sends multiple questions quickly, AI responds immediately + // Backend processing causes timestamp collisions + const messages = [ + createMessage( + "ai-2", + "2025-08-29 08:51:22 UTC", + false, + "Second AI response", + ), + createMessage( + "user-2", + "2025-08-29 08:51:22 UTC", + true, + "Second question", + ), + createMessage( + "ai-1", + "2025-08-29 08:51:21 UTC", + false, + "First AI response", + ), + createMessage( + "user-1", + "2025-08-29 08:51:21 UTC", + true, + "First question", + ), + createMessage( + "ai-3", + "2025-08-29 08:51:23 UTC", + false, + "Third AI response", + ), + createMessage( + "user-3", + "2025-08-29 08:51:23 UTC", + true, + "Third question", + ), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + // Expected chronological order: User → AI → User → AI → User → AI + const expectedOrder = [ + "user-1", + "ai-1", + "user-2", + "ai-2", + "user-3", + "ai-3", + ]; + expect(sorted.map((m) => m.id)).toEqual(expectedOrder); + + // Verify conversation flow pattern + for (let i = 0; i < sorted.length; i += 2) { + expect(sorted[i].isSend).toBe(true); // User message + if (i + 1 < sorted.length) { + expect(sorted[i + 1].isSend).toBe(false); // AI response + } + } + }); + + it("should handle timezone discrepancy scenarios", () => { + // Original issue: Frontend UTC+0, Backend UTC+3, Database UTC+3 → UTC+0 + // Messages generated at "same time" but with different timezone representations + const messages = [ + createMessage( + "ai-utc", + "2025-08-29 08:51:21 UTC", + false, + "AI response in UTC", + ), + createMessage( + "user-utc", + "2025-08-29 08:51:21 UTC", + true, + "User message in UTC", + ), + // These represent the same moment in time but different timezone formats + createMessage( + "ai-iso", + "2025-08-29T08:51:21Z", + false, + "AI response ISO", + ), + createMessage( + "user-iso", + "2025-08-29T08:51:21Z", + true, + "User message ISO", + ), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + // Should group by actual timestamp, with users first in each group + // All four messages have the same timestamp, so users should come first + expect(sorted[0].isSend).toBe(true); // user-utc + expect(sorted[1].isSend).toBe(true); // user-iso (also user, same timestamp) + expect(sorted[2].isSend).toBe(false); // ai-utc + expect(sorted[3].isSend).toBe(false); // ai-iso + }); + }); + + describe("Backend streaming edge cases", () => { + it("should handle Agent component message generation", () => { + // Agent component generates messages with sender="Machine" and different names + const messages = [ + createMessage( + "agent-step-2", + "2025-08-29 08:51:21 UTC", + false, + "Agent processing step 2", + ), + createMessage( + "user-query", + "2025-08-29 08:51:21 UTC", + true, + "Process this request", + ), + createMessage( + "agent-step-1", + "2025-08-29 08:51:21 UTC", + false, + "Agent processing step 1", + ), + createMessage( + "agent-result", + "2025-08-29 08:51:21 UTC", + false, + "Agent final result", + ), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + // User query should be first + expect(sorted[0].id).toBe("user-query"); + expect(sorted[0].isSend).toBe(true); + + // Agent messages should follow in original order (stable sort) + const agentMessages = sorted.slice(1); + expect(agentMessages.every((m) => !m.isSend)).toBe(true); + expect(agentMessages.map((m) => m.id)).toEqual([ + "agent-step-2", + "agent-step-1", + "agent-result", + ]); + }); + + it("should handle message updates during streaming", () => { + // Messages might be updated in place during streaming, causing re-sorts + const messages = [ + createMessage( + "stream-final", + "2025-08-29 08:51:21 UTC", + false, + "Complete response", + ), + createMessage( + "user-msg", + "2025-08-29 08:51:21 UTC", + true, + "User message", + ), + createMessage( + "stream-partial", + "2025-08-29 08:51:21 UTC", + false, + "Partial resp...", + ), + ]; + + // Multiple sorts should be stable + const sorted1 = [...messages].sort(sortSenderMessages); + const sorted2 = [...messages].sort(sortSenderMessages); + + expect(sorted1.map((m) => m.id)).toEqual(sorted2.map((m) => m.id)); + + // User message should consistently be first + expect(sorted1[0].id).toBe("user-msg"); + expect(sorted2[0].id).toBe("user-msg"); + }); + }); + + describe("Database precision and rounding", () => { + it("should handle database timestamp rounding", () => { + // Database might round timestamps, causing apparent simultaneity + const messages = [ + createMessage("msg-1", "2025-08-29 08:51:21.999 UTC", false), + createMessage("msg-2", "2025-08-29 08:51:21.001 UTC", true), + createMessage("msg-3", "2025-08-29 08:51:21.500 UTC", false), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + // Should sort by actual timestamp precision + expect(sorted[0].id).toBe("msg-2"); // .001 + expect(sorted[1].id).toBe("msg-3"); // .500 + expect(sorted[2].id).toBe("msg-1"); // .999 + }); + + it("should handle microsecond precision loss", () => { + // Original timestamps have microseconds, but database stores only seconds + const originalMessages = [ + { + timestamp: "2025-08-29 08:51:21.123456 UTC", + isSend: false, + id: "ai-micro", + }, + { + timestamp: "2025-08-29 08:51:21.123456 UTC", + isSend: true, + id: "user-micro", + }, + ]; + + // After database round-trip, microseconds are lost + const dbMessages = originalMessages.map((m) => + createMessage(m.id, "2025-08-29 08:51:21 UTC", m.isSend), + ); + + const sorted = dbMessages.sort(sortSenderMessages); + + // User should still come first despite identical timestamps + expect(sorted[0].id).toBe("user-micro"); + expect(sorted[1].id).toBe("ai-micro"); + }); + }); + + describe("Real conversation patterns", () => { + it("should handle typical question-answer flow", () => { + // Realistic conversation with multiple exchanges + const conversation = [ + // Third exchange (out of order in array) + createMessage( + "ai-3", + "2025-08-29 08:51:25 UTC", + false, + "Python is great for beginners because...", + ), + createMessage( + "user-3", + "2025-08-29 08:51:25 UTC", + true, + "What programming language should I learn?", + ), + + // First exchange + createMessage( + "ai-1", + "2025-08-29 08:51:21 UTC", + false, + "Hello! I'm an AI assistant. How can I help you?", + ), + createMessage("user-1", "2025-08-29 08:51:21 UTC", true, "Hello"), + + // Second exchange + createMessage( + "user-2", + "2025-08-29 08:51:23 UTC", + true, + "Can you help me with coding?", + ), + createMessage( + "ai-2", + "2025-08-29 08:51:23 UTC", + false, + "Absolutely! I'd be happy to help you with coding.", + ), + ]; + + const sorted = conversation.sort(sortSenderMessages); + + // Should create natural conversation flow + const expectedFlow = [ + { id: "user-1", type: "question" }, + { id: "ai-1", type: "answer" }, + { id: "user-2", type: "question" }, + { id: "ai-2", type: "answer" }, + { id: "user-3", type: "question" }, + { id: "ai-3", type: "answer" }, + ]; + + expectedFlow.forEach((expected, index) => { + expect(sorted[index].id).toBe(expected.id); + expect(sorted[index].isSend).toBe(expected.type === "question"); + }); + }); + + it("should handle error messages and system messages", () => { + // Mix of user messages, AI responses, and system errors + const messages = [ + createMessage( + "error-msg", + "2025-08-29 08:51:21 UTC", + false, + "Error: Connection timeout", + ), + createMessage( + "user-retry", + "2025-08-29 08:51:21 UTC", + true, + "Can you try again?", + ), + createMessage( + "user-original", + "2025-08-29 08:51:21 UTC", + true, + "What's the weather?", + ), + createMessage( + "ai-response", + "2025-08-29 08:51:21 UTC", + false, + "I don't have access to weather data", + ), + ]; + + const sorted = messages.sort(sortSenderMessages); + + // User messages should come first, maintaining their relative order + expect(sorted[0].isSend).toBe(true); + expect(sorted[1].isSend).toBe(true); + expect(sorted[2].isSend).toBe(false); + expect(sorted[3].isSend).toBe(false); + + // Verify relative order within same sender type is preserved + expect(sorted[0].id).toBe("user-retry"); // First user message in array + expect(sorted[1].id).toBe("user-original"); // Second user message in array + }); + }); + + describe("Performance regression tests", () => { + it("should maintain O(n log n) performance characteristics", () => { + const sizes = [10, 100, 1000]; + const timings: number[] = []; + + sizes.forEach((size) => { + const messages = Array.from({ length: size }, (_, i) => + createMessage( + `msg-${i}`, + `2025-08-29 08:${String(51 + (i % 10)).padStart(2, "0")}:${String(21 + (i % 60)).padStart(2, "0")} UTC`, + i % 3 === 0, // Mix of user and AI messages + ), + ); + + const startTime = performance.now(); + [...messages].sort(sortSenderMessages); + const endTime = performance.now(); + + timings.push(endTime - startTime); + }); + + // Verify performance scales reasonably (not exponentially) + // Each 10x increase in size should not cause 100x increase in time + expect(timings[1]).toBeLessThan(timings[0] * 50); // 100 items vs 10 items + expect(timings[2]).toBeLessThan(timings[1] * 50); // 1000 items vs 100 items + }); + + it("should handle worst-case scenario: all messages have identical timestamps", () => { + // Stress test: 500 messages all with same timestamp + const messages = Array.from({ length: 500 }, (_, i) => + createMessage( + `msg-${i}`, + "2025-08-29 08:51:21 UTC", // Same timestamp for all + i % 2 === 0, + ), + ); + + const startTime = performance.now(); + const sorted = [...messages].sort(sortSenderMessages); + const endTime = performance.now(); + + expect(endTime - startTime).toBeLessThan(50); // Should complete in <50ms + expect(sorted.length).toBe(500); + + // All user messages should come before all AI messages + const userCount = sorted.filter((m) => m.isSend).length; + + // First userCount messages should be users, rest should be AI + for (let i = 0; i < userCount; i++) { + expect(sorted[i].isSend).toBe(true); + } + for (let i = userCount; i < sorted.length; i++) { + expect(sorted[i].isSend).toBe(false); + } + }); + }); + + describe("Backwards compatibility", () => { + it("should maintain sort stability for pre-fix messages", () => { + // Messages that would have been affected by the original bug + const legacyMessages = [ + createMessage( + "legacy-ai", + "2025-08-29 08:50:23 UTC", + false, + "Code: None", + ), + createMessage( + "legacy-user", + "2025-08-29 08:50:24 UTC", + true, + "Who are you?", + ), + ]; + + const sorted = legacyMessages.sort(sortSenderMessages); + + // These should still sort correctly by timestamp + expect(sorted[0].id).toBe("legacy-ai"); // Earlier timestamp + expect(sorted[1].id).toBe("legacy-user"); // Later timestamp + }); + }); +}); diff --git a/src/frontend/src/modals/IOModal/components/chatView/__tests__/message-sorting-integration.test.ts b/src/frontend/src/modals/IOModal/components/chatView/__tests__/message-sorting-integration.test.ts new file mode 100644 index 000000000000..19c94795f3c8 --- /dev/null +++ b/src/frontend/src/modals/IOModal/components/chatView/__tests__/message-sorting-integration.test.ts @@ -0,0 +1,366 @@ +/** + * Integration tests for message sorting functionality. + * Tests how the sortSenderMessages function integrates with real message data structures + * and simulates the exact data transformation that happens in the chat-view component. + */ +import type { ChatMessageType } from "../../../../../types/chat"; +import sortSenderMessages from "../helpers/sort-sender-messages"; + +// Helper to create messages like they come from the backend/store +const createStoreMessage = ( + id: string, + timestamp: string, + sender: "User" | "Machine", + text: string, + flow_id: string = "test-flow-id", +) => ({ + id, + timestamp, + sender, + sender_name: sender === "User" ? "User" : "AI", + text, + session_id: "test-session", + flow_id, + files: [], + edit: false, + error: false, + category: "message", + properties: {}, + content_blocks: [], +}); + +// Helper to simulate the transformation that chat-view.tsx does +const transformMessages = (storeMessages: any[]): ChatMessageType[] => { + return storeMessages + .filter((message) => message.flow_id === "test-flow-id") + .map((message) => ({ + isSend: message.sender === "User", + message: message.text, + sender_name: + message.sender_name || (message.sender === "User" ? "User" : "AI"), + files: message.files || [], + id: message.id, + timestamp: message.timestamp, + session: message.session_id, + edit: message.edit || false, + category: message.category || "message", + properties: message.properties || {}, + content_blocks: message.content_blocks || [], + })); +}; + +describe("Message Sorting Integration", () => { + describe("Real component data flow simulation", () => { + it("should correctly sort messages through the full data transformation pipeline", () => { + // Simulate messages arriving from backend in random order + const storeMessages = [ + createStoreMessage( + "ai-response", + "2025-08-29 08:51:23 UTC", + "Machine", + "AI response", + ), + createStoreMessage( + "user-question", + "2025-08-29 08:51:21 UTC", + "User", + "User question", + ), + createStoreMessage( + "ai-followup", + "2025-08-29 08:51:22 UTC", + "Machine", + "AI followup", + ), + ]; + + // Transform like the real component + const transformedMessages = transformMessages(storeMessages); + + // Sort using our function + const sortedMessages = [...transformedMessages].sort(sortSenderMessages); + + // Verify chronological order + expect(sortedMessages.map((m) => m.id)).toEqual([ + "user-question", // 08:51:21 + "ai-followup", // 08:51:22 + "ai-response", // 08:51:23 + ]); + }); + + it("should handle the GitHub issue #9186 scenario", () => { + // Exact scenario from the GitHub issue: identical timestamps causing swaps + const problematicMessages = [ + createStoreMessage( + "ai-resp", + "2025-08-29 08:51:21 UTC", + "Machine", + "I'm an AI language model", + ), + createStoreMessage( + "user-q", + "2025-08-29 08:51:21 UTC", + "User", + "Who are you?", + ), + ]; + + const transformed = transformMessages(problematicMessages); + const sorted = [...transformed].sort(sortSenderMessages); + + // User question should come first, even though AI was first in the array + expect(sorted[0].id).toBe("user-q"); + expect(sorted[0].isSend).toBe(true); + expect(sorted[1].id).toBe("ai-resp"); + expect(sorted[1].isSend).toBe(false); + }); + + it("should handle streaming conversation with load balancer timing issues", () => { + // Scenario: streaming + load balancer causes identical timestamps + const streamingMessages = [ + createStoreMessage( + "stream-chunk-2", + "2025-08-29 08:51:21 UTC", + "Machine", + "I can help with", + ), + createStoreMessage( + "user-input", + "2025-08-29 08:51:21 UTC", + "User", + "Can you help me code?", + ), + createStoreMessage( + "stream-chunk-1", + "2025-08-29 08:51:21 UTC", + "Machine", + "Of course!", + ), + ]; + + const transformed = transformMessages(streamingMessages); + const sorted = [...transformed].sort(sortSenderMessages); + + // User input should be first + expect(sorted[0].id).toBe("user-input"); + expect(sorted[0].isSend).toBe(true); + + // AI streaming chunks should follow in original order (stable sort) + expect(sorted[1].isSend).toBe(false); + expect(sorted[2].isSend).toBe(false); + expect(sorted[1].id).toBe("stream-chunk-2"); + expect(sorted[2].id).toBe("stream-chunk-1"); + }); + }); + + describe("Data consistency validation", () => { + it("should maintain referential integrity after sorting", () => { + const originalMessages = [ + createStoreMessage( + "msg1", + "2025-08-29 08:51:21 UTC", + "User", + "Original text", + "test-flow-id", + ), + createStoreMessage( + "msg2", + "2025-08-29 08:51:21 UTC", + "Machine", + "AI response", + "test-flow-id", + ), + ]; + + const transformed = transformMessages(originalMessages); + const sorted = [...transformed].sort(sortSenderMessages); + + // Verify all properties are maintained + expect(sorted.length).toBe(2); + + // User message should come first (due to identical timestamps) + expect(sorted[0].isSend).toBe(true); + expect(sorted[0].message).toBe("Original text"); + expect(sorted[0].session).toBe("test-session"); + + expect(sorted[1].isSend).toBe(false); + expect(sorted[1].message).toBe("AI response"); + expect(sorted[1].session).toBe("test-session"); + }); + + it("should handle filtering by flow_id correctly", () => { + const mixedFlowMessages = [ + createStoreMessage( + "msg1", + "2025-08-29 08:51:21 UTC", + "User", + "Message 1", + "test-flow-id", + ), + createStoreMessage( + "msg2", + "2025-08-29 08:51:22 UTC", + "Machine", + "Message 2", + "other-flow", + ), + createStoreMessage( + "msg3", + "2025-08-29 08:51:23 UTC", + "User", + "Message 3", + "test-flow-id", + ), + ]; + + const transformed = transformMessages(mixedFlowMessages); + const sorted = [...transformed].sort(sortSenderMessages); + + // Only messages from test-flow-id should be included + expect(sorted.length).toBe(2); + expect(sorted.map((m) => m.id)).toEqual(["msg1", "msg3"]); + }); + }); + + describe("Performance with realistic data volumes", () => { + it("should handle typical chat session sizes efficiently", () => { + // Simulate a realistic chat session (50 message exchanges) + const chatSession = Array.from({ length: 100 }, (_, i) => { + const isUser = i % 2 === 0; + return createStoreMessage( + `msg-${i}`, + `2025-08-29 08:${String(51 + Math.floor(i / 10)).padStart(2, "0")}:${String(21 + (i % 60)).padStart(2, "0")} UTC`, + isUser ? "User" : "Machine", + `Message ${i}`, + ); + }); + + const startTime = performance.now(); + const transformed = transformMessages(chatSession); + const sorted = [...transformed].sort(sortSenderMessages); + const endTime = performance.now(); + + expect(sorted.length).toBe(100); + expect(endTime - startTime).toBeLessThan(10); // Should be very fast + + // Verify chronological order (skip invalid timestamps) + for (let i = 1; i < sorted.length; i++) { + const prevTime = new Date(sorted[i - 1].timestamp).getTime(); + const currTime = new Date(sorted[i].timestamp).getTime(); + if (!isNaN(prevTime) && !isNaN(currTime)) { + expect(prevTime).toBeLessThanOrEqual(currTime); + } + } + }); + + it("should maintain O(n log n) performance characteristics", () => { + const sizes = [50, 200, 500]; + const timings: number[] = []; + + sizes.forEach((size) => { + const messages = Array.from({ length: size }, (_, i) => + createStoreMessage( + `msg-${i}`, + `2025-08-29 08:${String(51 + (i % 10)).padStart(2, "0")}:${String(21 + (i % 40)).padStart(2, "0")} UTC`, + i % 3 === 0 ? "User" : "Machine", + `Message ${i}`, + ), + ); + + const transformed = transformMessages(messages); + + const startTime = performance.now(); + [...transformed].sort(sortSenderMessages); + const endTime = performance.now(); + + timings.push(endTime - startTime); + }); + + // Performance should scale sub-quadratically + expect(timings[1]).toBeLessThan(timings[0] * 10); // 4x size, <10x time + expect(timings[2]).toBeLessThan(timings[1] * 5); // 2.5x size, <5x time + }); + }); + + describe("Edge cases in real usage", () => { + it("should handle malformed timestamps gracefully", () => { + const messagesWithBadTimestamps = [ + createStoreMessage("bad1", "invalid-date", "User", "Bad timestamp"), + createStoreMessage( + "good1", + "2025-08-29 08:51:21 UTC", + "Machine", + "Good timestamp", + ), + createStoreMessage("bad2", "", "User", "Empty timestamp"), + ]; + + const transformed = transformMessages(messagesWithBadTimestamps); + + // Should not throw + expect(() => [...transformed].sort(sortSenderMessages)).not.toThrow(); + + const sorted = [...transformed].sort(sortSenderMessages); + expect(sorted.length).toBe(3); + }); + + it("should handle messages with missing properties", () => { + const incompleteMessages = [ + // Missing some properties + { + id: "incomplete1", + timestamp: "2025-08-29 08:51:21 UTC", + sender: "User", + text: "Incomplete message", + session_id: "test-session", + flow_id: "test-flow-id", + }, + createStoreMessage( + "complete1", + "2025-08-29 08:51:22 UTC", + "Machine", + "Complete message", + ), + ] as any; + + const transformed = transformMessages(incompleteMessages); + const sorted = [...transformed].sort(sortSenderMessages); + + expect(sorted.length).toBe(2); + expect(sorted[0].id).toBe("incomplete1"); // Earlier timestamp + expect(sorted[1].id).toBe("complete1"); + }); + }); + + describe("Backwards compatibility", () => { + it("should work with legacy message formats", () => { + // Simulate messages that might exist from before the fix + const legacyMessages = [ + { + ...createStoreMessage( + "legacy1", + "2025-08-29 08:51:21 UTC", + "Machine", + "Legacy AI message", + ), + // Legacy format might not have all modern properties + }, + createStoreMessage( + "modern1", + "2025-08-29 08:51:21 UTC", + "User", + "Modern user message", + ), + ]; + + const transformed = transformMessages(legacyMessages); + const sorted = [...transformed].sort(sortSenderMessages); + + // User message should come first due to our sorting logic + expect(sorted[0].id).toBe("modern1"); + expect(sorted[0].isSend).toBe(true); + expect(sorted[1].id).toBe("legacy1"); + expect(sorted[1].isSend).toBe(false); + }); + }); +}); diff --git a/src/frontend/src/modals/IOModal/components/chatView/__tests__/sort-sender-messages.test.ts b/src/frontend/src/modals/IOModal/components/chatView/__tests__/sort-sender-messages.test.ts new file mode 100644 index 000000000000..95bbd1c5a777 --- /dev/null +++ b/src/frontend/src/modals/IOModal/components/chatView/__tests__/sort-sender-messages.test.ts @@ -0,0 +1,409 @@ +import type { ChatMessageType } from "../../../../../types/chat"; +import sortSenderMessages from "../helpers/sort-sender-messages"; + +// Helper function to create mock ChatMessageType +const createMockMessage = ( + timestamp: string, + isSend: boolean, + id: string = "test-id", + message: string = "test message", +): ChatMessageType => ({ + id, + timestamp, + isSend, + message, + sender_name: isSend ? "User" : "AI", + session: "test-session", + files: [], + edit: false, + category: "message", + properties: { + source: { id: "test", display_name: "Test", source: "test" }, + }, + content_blocks: [], +}); + +describe("sortSenderMessages", () => { + describe("Primary sorting by timestamp", () => { + it("should sort messages by timestamp in ascending order", () => { + const messages = [ + createMockMessage("2025-08-29 08:51:23 UTC", true, "msg3"), + createMockMessage("2025-08-29 08:51:21 UTC", true, "msg1"), + createMockMessage("2025-08-29 08:51:22 UTC", false, "msg2"), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + expect(sorted.map((m) => m.id)).toEqual(["msg1", "msg2", "msg3"]); + expect(new Date(sorted[0].timestamp).getTime()).toBeLessThan( + new Date(sorted[1].timestamp).getTime(), + ); + expect(new Date(sorted[1].timestamp).getTime()).toBeLessThan( + new Date(sorted[2].timestamp).getTime(), + ); + }); + + it("should handle different timestamp formats", () => { + const messages = [ + createMockMessage("2025-08-29T08:51:23Z", false, "iso"), + createMockMessage("2025-08-29 08:51:21 UTC", true, "utc"), + createMockMessage("2025-08-29 08:51:22", true, "no-tz"), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + // Check that first is earliest, last is latest + const sortedTimes = sorted.map((m) => new Date(m.timestamp).getTime()); + expect(sortedTimes[0]).toBeLessThan(sortedTimes[1]); + expect(sortedTimes[1]).toBeLessThan(sortedTimes[2]); + expect(sorted[0].id).toBe("utc"); // 08:51:21 is earliest + }); + + it("should handle messages spanning multiple days", () => { + const messages = [ + createMockMessage("2025-08-30 08:51:21 UTC", true, "day2"), + createMockMessage("2025-08-29 08:51:21 UTC", false, "day1"), + createMockMessage("2025-08-31 08:51:21 UTC", true, "day3"), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + expect(sorted.map((m) => m.id)).toEqual(["day1", "day2", "day3"]); + }); + }); + + describe("Secondary sorting for identical timestamps", () => { + it("should place User messages before AI messages when timestamps are identical", () => { + const messages = [ + createMockMessage( + "2025-08-29 08:51:21 UTC", + false, + "ai-msg", + "AI response", + ), + createMockMessage( + "2025-08-29 08:51:21 UTC", + true, + "user-msg", + "User question", + ), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + expect(sorted[0].id).toBe("user-msg"); + expect(sorted[0].isSend).toBe(true); + expect(sorted[1].id).toBe("ai-msg"); + expect(sorted[1].isSend).toBe(false); + }); + + it("should maintain User-first order in multiple identical timestamp pairs", () => { + const messages = [ + createMockMessage("2025-08-29 08:51:21 UTC", false, "ai1"), + createMockMessage("2025-08-29 08:51:21 UTC", true, "user1"), + createMockMessage("2025-08-29 08:51:21 UTC", false, "ai2"), + createMockMessage("2025-08-29 08:51:21 UTC", true, "user2"), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + // Users should come first, then AIs, but maintain relative order within same type + expect(sorted[0].isSend).toBe(true); // user1 + expect(sorted[1].isSend).toBe(true); // user2 + expect(sorted[2].isSend).toBe(false); // ai1 + expect(sorted[3].isSend).toBe(false); // ai2 + }); + + it("should preserve original order for messages with same timestamp and sender type", () => { + const messages = [ + createMockMessage( + "2025-08-29 08:51:21 UTC", + true, + "user2", + "Second user message", + ), + createMockMessage( + "2025-08-29 08:51:21 UTC", + true, + "user1", + "First user message", + ), + createMockMessage( + "2025-08-29 08:51:21 UTC", + true, + "user3", + "Third user message", + ), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + // Order should be preserved when timestamps and sender types are identical + expect(sorted.map((m) => m.id)).toEqual(["user2", "user1", "user3"]); + expect(sorted.every((m) => m.isSend)).toBe(true); + }); + }); + + describe("Complex conversation scenarios", () => { + it("should handle a realistic conversation with mixed timestamps", () => { + const messages = [ + createMockMessage( + "2025-08-29 08:51:23 UTC", + false, + "ai2", + "Second AI response", + ), + createMockMessage( + "2025-08-29 08:51:21 UTC", + true, + "user1", + "First question", + ), + createMockMessage( + "2025-08-29 08:51:21 UTC", + false, + "ai1", + "First AI response", + ), + createMockMessage( + "2025-08-29 08:51:23 UTC", + true, + "user2", + "Follow-up question", + ), + createMockMessage( + "2025-08-29 08:51:25 UTC", + true, + "user3", + "Third question", + ), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + expect(sorted.map((m) => m.id)).toEqual([ + "user1", // 08:51:21, User first + "ai1", // 08:51:21, AI second + "user2", // 08:51:23, User first + "ai2", // 08:51:23, AI second + "user3", // 08:51:25, only message at this time + ]); + }); + + it("should handle conversation with streaming responses (identical timestamps)", () => { + // Simulate scenario where user sends message and AI responds immediately + // causing identical timestamps due to backend streaming/load balancer + const messages = [ + createMockMessage( + "2025-08-29 08:51:21 UTC", + false, + "stream-ai", + "Streaming AI response", + ), + createMockMessage( + "2025-08-29 08:51:21 UTC", + true, + "stream-user", + "User message", + ), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + expect(sorted[0].id).toBe("stream-user"); + expect(sorted[0].message).toBe("User message"); + expect(sorted[1].id).toBe("stream-ai"); + expect(sorted[1].message).toBe("Streaming AI response"); + }); + + it("should handle conversation with multiple AI responses to one user message", () => { + const messages = [ + createMockMessage( + "2025-08-29 08:51:21 UTC", + false, + "ai-chunk-1", + "AI chunk 1", + ), + createMockMessage( + "2025-08-29 08:51:21 UTC", + false, + "ai-chunk-2", + "AI chunk 2", + ), + createMockMessage( + "2025-08-29 08:51:21 UTC", + true, + "user-msg", + "User question", + ), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + expect(sorted[0].id).toBe("user-msg"); + expect(sorted[0].isSend).toBe(true); + // AI messages should come after, preserving their original order + expect(sorted[1].id).toBe("ai-chunk-1"); + expect(sorted[2].id).toBe("ai-chunk-2"); + expect(sorted[1].isSend).toBe(false); + expect(sorted[2].isSend).toBe(false); + }); + }); + + describe("Edge cases", () => { + it("should handle empty array", () => { + const messages: ChatMessageType[] = []; + const sorted = [...messages].sort(sortSenderMessages); + expect(sorted).toEqual([]); + }); + + it("should handle single message", () => { + const messages = [ + createMockMessage("2025-08-29 08:51:21 UTC", true, "single"), + ]; + const sorted = [...messages].sort(sortSenderMessages); + expect(sorted.length).toBe(1); + expect(sorted[0].id).toBe("single"); + }); + + it("should handle invalid timestamp formats gracefully", () => { + // Note: new Date() with invalid strings returns Invalid Date + // but getTime() on Invalid Date returns NaN + // NaN comparisons always return false, so original order should be preserved + const messages = [ + createMockMessage("invalid-timestamp", false, "invalid-ai"), + createMockMessage("2025-08-29 08:51:21 UTC", true, "valid-user"), + createMockMessage("another-invalid", true, "invalid-user"), + ]; + + // Should not throw an error + expect(() => [...messages].sort(sortSenderMessages)).not.toThrow(); + + const sorted = [...messages].sort(sortSenderMessages); + expect(sorted.length).toBe(3); + + // Valid timestamp should be sorted correctly relative to others + const validMessage = sorted.find((m) => m.id === "valid-user"); + expect(validMessage).toBeDefined(); + }); + + it("should handle millisecond precision timestamps", () => { + const messages = [ + createMockMessage("2025-08-29 08:51:21.002 UTC", false, "ai-milli2"), + createMockMessage("2025-08-29 08:51:21.001 UTC", true, "user-milli1"), + createMockMessage("2025-08-29 08:51:21.001 UTC", false, "ai-milli1"), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + // Test the key behavior: chronological order with user-first for identical timestamps + expect(sorted.length).toBe(3); + + // Messages with .001 should come before .002 + const firstTwoTimes = sorted + .slice(0, 2) + .map((m) => new Date(m.timestamp).getTime()); + const thirdTime = new Date(sorted[2].timestamp).getTime(); + + expect(firstTwoTimes[0]).toEqual(firstTwoTimes[1]); // Same millisecond + expect(firstTwoTimes[0]).toBeLessThan(thirdTime); // Earlier than third + + // Among the first two (same timestamp), user should come first + const sameTimestampMessages = sorted.slice(0, 2); + const userMsg = sameTimestampMessages.find((m) => m.isSend); + const aiMsg = sameTimestampMessages.find((m) => !m.isSend); + + expect(userMsg?.id).toBe("user-milli1"); + expect(aiMsg?.id).toBe("ai-milli1"); + expect(sorted.indexOf(userMsg!)).toBeLessThan(sorted.indexOf(aiMsg!)); + }); + + it("should handle timezone differences", () => { + const messages = [ + createMockMessage("2025-08-29 11:51:21 UTC", true, "utc"), + createMockMessage("2025-08-29T08:51:21-03:00", false, "minus3"), // Same as 11:51:21 UTC + createMockMessage("2025-08-29 08:51:21 UTC", true, "utc-earlier"), + ]; + + const sorted = [...messages].sort(sortSenderMessages); + + expect(sorted[0].id).toBe("utc-earlier"); // 08:51:21 UTC earliest + // Next two have same time (11:51:21 UTC), user should come first + expect(sorted[1].id).toBe("utc"); + expect(sorted[2].id).toBe("minus3"); + }); + }); + + describe("Performance and stability", () => { + it("should be a stable sort for identical elements", () => { + const messages = [ + createMockMessage("2025-08-29 08:51:21 UTC", true, "user-a"), + createMockMessage("2025-08-29 08:51:21 UTC", true, "user-b"), + createMockMessage("2025-08-29 08:51:21 UTC", true, "user-c"), + ]; + + const sorted1 = [...messages].sort(sortSenderMessages); + const sorted2 = [...messages].sort(sortSenderMessages); + + // Should produce consistent results + expect(sorted1.map((m) => m.id)).toEqual(sorted2.map((m) => m.id)); + }); + + it("should handle large arrays efficiently", () => { + // Generate 1000 messages with mixed timestamps + const messages = Array.from({ length: 1000 }, (_, i) => + createMockMessage( + `2025-08-29 08:${String(51 + (i % 10)).padStart(2, "0")}:${String(21 + (i % 40)).padStart(2, "0")} UTC`, + i % 2 === 0, // Alternate between user and AI + `msg-${i}`, + ), + ); + + const startTime = performance.now(); + const sorted = [...messages].sort(sortSenderMessages); + const endTime = performance.now(); + + expect(sorted.length).toBe(1000); + expect(endTime - startTime).toBeLessThan(100); // Should complete in <100ms + + // Verify sorting is correct - timestamps should be chronological + for (let i = 1; i < sorted.length; i++) { + const prevTime = new Date(sorted[i - 1].timestamp).getTime(); + const currTime = new Date(sorted[i].timestamp).getTime(); + // Skip comparison if either timestamp is invalid (NaN) + if (!isNaN(prevTime) && !isNaN(currTime)) { + expect(prevTime).toBeLessThanOrEqual(currTime); + } + } + }); + }); + + describe("Type safety", () => { + it("should work with all required ChatMessageType properties", () => { + const message: ChatMessageType = { + id: "complete-msg", + timestamp: "2025-08-29 08:51:21 UTC", + isSend: true, + message: "Complete message object", + sender_name: "Test User", + session: "test-session", + files: [{ path: "/test", type: "text", name: "test.txt" }], + edit: false, + category: "message", + properties: { + source: { id: "test", display_name: "Test", source: "test" }, + }, + content_blocks: [], + template: "test template", + thought: "test thought", + prompt: "test prompt", + chatKey: "test-key", + componentId: "test-component", + stream_url: "test-stream", + }; + + expect(() => sortSenderMessages(message, message)).not.toThrow(); + expect(sortSenderMessages(message, message)).toBe(0); + }); + }); +}); diff --git a/src/frontend/src/modals/IOModal/components/chatView/components/chat-view.tsx b/src/frontend/src/modals/IOModal/components/chatView/components/chat-view.tsx index 07e6dc453a5e..2f7f5d936592 100644 --- a/src/frontend/src/modals/IOModal/components/chatView/components/chat-view.tsx +++ b/src/frontend/src/modals/IOModal/components/chatView/components/chat-view.tsx @@ -19,6 +19,7 @@ import type { chatViewProps } from "../../../../../types/components"; import FlowRunningSqueleton from "../../flow-running-squeleton"; import useDragAndDrop from "../chatInput/hooks/use-drag-and-drop"; import ChatMessage from "../chatMessage/chat-message"; +import sortSenderMessages from "../helpers/sort-sender-messages"; const MemoizedChatMessage = memo(ChatMessage, (prevProps, nextProps) => { return ( @@ -102,9 +103,10 @@ export default function ChatView({ properties: message.properties || {}, }; }); - const finalChatHistory = [...messagesFromMessagesStore].sort((a, b) => { - return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); - }); + + const finalChatHistory = [...messagesFromMessagesStore].sort( + sortSenderMessages, + ); if (messages.length === 0 && !isBuilding && chatInputNode && isTabHidden) { setChatValueStore( diff --git a/src/frontend/src/modals/IOModal/components/chatView/helpers/sort-sender-messages.ts b/src/frontend/src/modals/IOModal/components/chatView/helpers/sort-sender-messages.ts new file mode 100644 index 000000000000..5c6442bfe8da --- /dev/null +++ b/src/frontend/src/modals/IOModal/components/chatView/helpers/sort-sender-messages.ts @@ -0,0 +1,37 @@ +import type { ChatMessageType } from "../../../../../types/chat"; + +/** + * Sorts chat messages by timestamp with proper handling of identical timestamps. + * + * Primary sort: By timestamp (chronological order) + * Secondary sort: When timestamps are identical, User messages (isSend=true) come before AI/Machine messages (isSend=false) + * + * This ensures proper conversation flow even when backend generates identical timestamps + * due to streaming, load balancing, or database precision limitations. + * + * @param a - First chat message to compare + * @param b - Second chat message to compare + * @returns Sort comparison result (-1, 0, 1) + */ +const sortSenderMessages = (a: ChatMessageType, b: ChatMessageType): number => { + const timeA = new Date(a.timestamp).getTime(); + const timeB = new Date(b.timestamp).getTime(); + + // Primary sort: by timestamp + if (timeA !== timeB) { + return timeA - timeB; + } + + // Secondary sort: if timestamps are identical, User messages come before AI/Machine + // This ensures proper chronological order when backend generates identical timestamps + if (a.isSend && !b.isSend) { + return -1; // User message (isSend=true) comes first + } + if (!a.isSend && b.isSend) { + return 1; // User message (isSend=true) comes first + } + + return 0; // Keep original order for same sender types +}; + +export default sortSenderMessages;