diff --git a/web/src/engine/messageIdentity.test.ts b/web/src/engine/messageIdentity.test.ts new file mode 100644 index 00000000..ac8dbbfe --- /dev/null +++ b/web/src/engine/messageIdentity.test.ts @@ -0,0 +1,48 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isSameMessage, + filterPendingLiveMessages, + mergeLiveOverHistory, +} from './messageIdentity'; +import type { ChatMessage } from '../types/workbench'; + +const base = (patch: Partial): ChatMessage => ({ + id: 'tmp-1', + sessionId: 'sess-1', + role: 'user', + content: 'hello', + timestamp: '10:00', + ...patch, +}); + +test('isSameMessage: clientTurnId match', () => { + const local = base({ id: 'tmp-user', clientTurnId: 'turn-abc' }); + const server = base({ id: 'msg-101', serverId: 101, clientTurnId: 'turn-abc' }); + assert.equal(isSameMessage(local, server), true); +}); + +test('isSameMessage: serverId match without turn', () => { + const local = base({ id: 'ast-123', serverId: 102, role: 'assistant' }); + const server = base({ id: 'msg-102', serverId: 102, role: 'assistant' }); + assert.equal(isSameMessage(local, server), true); +}); + +test('filterPendingLiveMessages: real temp vs server id', () => { + const history = [base({ id: 'msg-101', serverId: 101, clientTurnId: 'turn-abc' })]; + const live = [ + base({ id: 'tmp-user', clientTurnId: 'turn-abc' }), + base({ id: 'tmp-asst', role: 'assistant', clientTurnId: 'turn-abc', serverId: 102 }), + ]; + const pending = filterPendingLiveMessages(live, history); + assert.equal(pending.length, 1); + assert.equal(pending[0].role, 'assistant'); +}); + +test('mergeLiveOverHistory preserves server id from live', () => { + const history = base({ id: 'msg-101', serverId: 101, content: 'hello' }); + const live = base({ id: 'tmp-user', serverId: 101, content: 'hello', status: undefined }); + const merged = mergeLiveOverHistory(history, live); + assert.equal(merged.id, 'msg-101'); + assert.equal(merged.serverId, 101); +}); diff --git a/web/src/tests/workflow-chat-chronology.test.ts b/web/src/tests/workflow-chat-chronology.test.ts index dc307616..d5982502 100644 --- a/web/src/tests/workflow-chat-chronology.test.ts +++ b/web/src/tests/workflow-chat-chronology.test.ts @@ -2,6 +2,7 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { flattenDayStack } from '../hooks/useVirtualMessages'; import { ChatDayBucket, ChatMessage } from '../types/workbench'; +import { filterPendingLiveMessages } from '../engine/messageIdentity'; describe('工作流切换与消息展示时序测试套件', () => { test('1. 当存在历史 seed 消息且追加新实时消息时,新消息必须严格沉淀在末尾', () => { @@ -12,8 +13,11 @@ describe('工作流切换与消息展示时序测试套件', () => { { id: 'seed-4', sessionId: 'sess-1', role: 'assistant', content: '历史答复2', timestamp: '09:11' }, ]; + const clientTurnId = 'turn-live-abc'; const newlySentUserMsg: ChatMessage = { - id: 'msg-live-1', + id: 'tmp-user-uuid', + clientTurnId, + clientMessageId: `${clientTurnId}:user`, sessionId: 'sess-1', role: 'user', content: '分析一下测试方案中,有哪些比较难的项目,以及需要特别注意的地方', @@ -21,46 +25,61 @@ describe('工作流切换与消息展示时序测试套件', () => { }; const newlyRepliedAsstMsg: ChatMessage = { - id: 'ast-live-2', + id: 'tmp-asst-uuid', + clientTurnId, + clientMessageId: `${clientTurnId}:assistant`, sessionId: 'sess-1', role: 'assistant', content: '针对肌骨超声AI辅助诊断系统的系统验证方案...', timestamp: '10:01', + serverId: 102, }; - const fullLiveMessages: ChatMessage[] = [ - ...historicalMessages, - newlySentUserMsg, - newlyRepliedAsstMsg, - ]; + // 切回项目时 live 仅保留进行中/未对账的 temp 消息(历史已由服务端窗口提供) + const fullLiveMessages: ChatMessage[] = [newlySentUserMsg, newlyRepliedAsstMsg]; - // 模拟服务端只持久化了新返回的 2 条记录 (原 Bug 触发场景) + // 服务端窗口含历史 + 新落库消息(真实 ID 与本地 temp 不同) const serverDayStack: ChatDayBucket[] = [ { dayKey: '2026-09-11', - count: 2, - loadedCount: 2, + count: 6, + loadedCount: 6, truncated: false, - firstMessageId: 101, + firstMessageId: 97, lastMessageId: 102, messages: [ - { ...newlySentUserMsg, serverId: 101 }, - { ...newlyRepliedAsstMsg, serverId: 102 }, + ...historicalMessages.map((m, i) => ({ + ...m, + id: `msg-${97 + i}`, + serverId: 97 + i, + })), + { + id: 'msg-101', + clientTurnId, + clientMessageId: `${clientTurnId}:user`, + sessionId: 'sess-1', + role: 'user' as const, + content: newlySentUserMsg.content, + timestamp: '10:00', + serverId: 101, + }, + { + id: 'msg-102', + clientTurnId, + clientMessageId: `${clientTurnId}:assistant`, + sessionId: 'sess-1', + role: 'assistant' as const, + content: newlyRepliedAsstMsg.content, + timestamp: '10:01', + serverId: 102, + }, ], }, ]; - // 运用修复后的保序合并算法 const todayStr = '2026-09-11'; - const existingMsgIds = new Set(); - for (const b of serverDayStack) { - for (const m of b.messages) { - existingMsgIds.add(m.id); - if (m.serverId) existingMsgIds.add(`msg-${m.serverId}`); - } - } - - const pendingLive = fullLiveMessages.filter((m) => !existingMsgIds.has(m.id)); + const historyMessages = serverDayStack.flatMap((b) => b.messages); + const pendingLive = filterPendingLiveMessages(fullLiveMessages, historyMessages); const result = serverDayStack.map((b) => ({ ...b, messages: [...b.messages] })); const todayBucket = result.find((b) => b.dayKey === todayStr)!; @@ -87,58 +106,49 @@ describe('工作流切换与消息展示时序测试套件', () => { const flat = flattenDayStack(result); - // 校验总数 assert.equal(flat.length, 6); - // 校验第 1 条依然是历史第一条,绝对没有被反转 - assert.equal(flat[0].id, 'seed-1'); - assert.equal(flat[1].id, 'seed-2'); - assert.equal(flat[2].id, 'seed-3'); - assert.equal(flat[3].id, 'seed-4'); - // 校验最后两条为刚刚发送的用户提问和助手回复,严格在最底部 - assert.equal(flat[4].id, 'msg-live-1'); - assert.equal(flat[5].id, 'ast-live-2'); + assert.equal(pendingLive.length, 0); + assert.equal(flat.filter((m) => m.clientTurnId === clientTurnId).length, 2); + assert.equal(flat[4].id, 'msg-101'); + assert.equal(flat[5].id, 'msg-102'); }); - test('2. 页面刷新后,即使本地 liveMessages 未包含新消息,按时间戳与自增 ID 排序也绝对不倒挂在历史消息前', () => { - // 模拟服务端返回完整按时间排布的消息 - const historicalMessages: ChatMessage[] = [ - { id: 'm-jigu-1', sessionId: 'sess-jigu-u1', role: 'system', content: '助手就位', timestamp: '09:00', createdAt: '2026-09-11T09:00:00+08:00', serverId: 25217 }, - { id: 'm-jigu-2', sessionId: 'sess-jigu-u1', role: 'user', content: '用户需求', timestamp: '09:14', createdAt: '2026-09-11T09:14:00+08:00', serverId: 25218 }, + test('2. 切回项目时相同正文不应因 temp/server id 差异重复展示', () => { + const clientTurnId = 'turn-jigu-dup'; + const liveMessages: ChatMessage[] = [ + { + id: 'tmp-user', + clientTurnId, + sessionId: 'sess-jigu-u1', + role: 'user', + content: '分析一下测试方案中...', + timestamp: '10:06', + serverId: 25227, + }, ]; - - const serverRecentMessages: ChatMessage[] = [ - { id: 'msg-25227', sessionId: 'sess-jigu-u1', role: 'user', content: '分析一下测试方案中...', timestamp: '10:06', createdAt: '2026-09-11T10:06:46+08:00', serverId: 25227 }, - { id: 'msg-25228', sessionId: 'sess-jigu-u1', role: 'assistant', content: '针对测试方案...', timestamp: '10:07', createdAt: '2026-09-11T10:07:26+08:00', serverId: 25228 }, + const serverDayStack: ChatDayBucket[] = [ + { + dayKey: '2026-09-11', + count: 1, + loadedCount: 1, + truncated: false, + firstMessageId: 25227, + lastMessageId: 25227, + messages: [ + { + id: 'msg-25227', + clientTurnId, + sessionId: 'sess-jigu-u1', + role: 'user', + content: '分析一下测试方案中...', + timestamp: '10:06', + serverId: 25227, + }, + ], + }, ]; - - const allInBucket = [...serverRecentMessages, ...historicalMessages]; // 故意打乱测试排序算法鲁棒性 - - const getMessageSortTime = (m: ChatMessage): number => { - if (m.createdAt) { - const t = new Date(m.createdAt).getTime(); - if (!isNaN(t)) return t; - } - if (m.timestamp && m.timestamp.includes(':')) { - const [hh, mm] = m.timestamp.split(':').map(Number); - if (!isNaN(hh) && !isNaN(mm)) { - const d = new Date(); - d.setHours(hh, mm, 0, 0); - return d.getTime(); - } - } - return 0; - }; - - allInBucket.sort((a, b) => { - const tA = getMessageSortTime(a); - const tB = getMessageSortTime(b); - if (tA !== 0 && tB !== 0 && tA !== tB) return tA - tB; - return (a.serverId || 0) - (b.serverId || 0); - }); - - assert.equal(allInBucket[0].id, 'm-jigu-1'); - assert.equal(allInBucket[1].id, 'm-jigu-2'); - assert.equal(allInBucket[2].id, 'msg-25227'); - assert.equal(allInBucket[3].id, 'msg-25228'); + const history = serverDayStack.flatMap((b) => b.messages); + const pending = filterPendingLiveMessages(liveMessages, history); + assert.equal(pending.length, 0); }); });