groupProtocol.test.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. import { describe, expect, it } from 'vitest'
  2. import type { GroupMessage, GroupSocketEnvelope } from '@shared/types'
  3. import {
  4. applyGroupEnvelope, classifyGroupEnvelope, type GroupProtocolState, upsertGroupMessage,
  5. } from '@/utils/groupProtocol'
  6. const epoch = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
  7. function groupMessage(
  8. id: string,
  9. clientMessageId: string,
  10. content: string,
  11. status: GroupMessage['status'],
  12. topicId: string | null = null,
  13. ): GroupMessage {
  14. return {
  15. seq: status === 'queued' ? 0 : 1, id, roomId: 'room-1', topicId, senderKind: 'human', senderId: 'me', senderName: '我',
  16. rootMessageId: id, clientMessageId, content, reasoning: '', toolState: [], status, error: '', createdAt: 1, updatedAt: 1,
  17. }
  18. }
  19. function initial(): GroupProtocolState {
  20. return {
  21. epoch, cursor: 8,
  22. rooms: [{ id: 'room-1', name: '群聊', cwd: '', createdAt: 1, updatedAt: 1, archived: false, agentCount: 1, unreadCount: 0, maxReplyRounds: 3 }],
  23. roomDetails: {}, topicsByRoom: { 'room-1': [] }, messagesByRoom: { 'room-1': [] }, messagesByTopic: {},
  24. }
  25. }
  26. describe('group protocol continuity', () => {
  27. it('accepts only exact ready and next cursor, ignoring duplicates', () => {
  28. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.ready', epoch, cursor: 8 })).toBe('ready')
  29. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 8 })).toBe('ignore')
  30. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 9 })).toBe('apply')
  31. })
  32. it('requires reset for gaps, epoch changes, and server reset', () => {
  33. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 10 })).toBe('reset')
  34. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch: 'other', cursor: 9 })).toBe('reset')
  35. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.reset_required', epoch, cursor: 8 })).toBe('reset')
  36. })
  37. it('reconciles optimistic group messages by clientMessageId', () => {
  38. const optimistic = groupMessage('local', 'client-1', 'hello', 'queued')
  39. const server = groupMessage('server', 'client-1', 'hello', 'completed')
  40. const merged = upsertGroupMessage([optimistic], server)
  41. expect(merged).toHaveLength(1)
  42. expect(merged[0]).toMatchObject({ id: 'server', status: 'completed' })
  43. })
  44. it('applies message events to their own room and advances the cursor', () => {
  45. const payload = groupMessage('server', 'client-1', 'answer', 'completed')
  46. const envelope: GroupSocketEnvelope = {
  47. type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'message.upsert', payload: payload as never,
  48. }
  49. const reduced = applyGroupEnvelope(initial(), envelope)
  50. expect(reduced.cursor).toBe(9)
  51. expect(reduced.messagesByRoom['room-1']).toHaveLength(1)
  52. expect(reduced.rooms[0].lastMessage?.id).toBe('server')
  53. })
  54. it('upserts topic.updated events in most-recently-active order', () => {
  55. const state = initial()
  56. state.topicsByRoom['room-1'] = [
  57. { id: 'topic-1', roomId: 'room-1', title: '旧标题', preview: '旧内容', messageCount: 1, createdAt: 1, updatedAt: 2 },
  58. { id: 'topic-2', roomId: 'room-1', title: '另一话题', preview: '另一内容', messageCount: 1, createdAt: 1, updatedAt: 4 },
  59. ]
  60. const envelope: GroupSocketEnvelope = {
  61. type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'topic.updated',
  62. payload: {
  63. id: 'topic-1', roomId: 'room-1', title: '新标题', preview: '最新内容',
  64. messageCount: 2, createdAt: 1, updatedAt: 5,
  65. },
  66. }
  67. const reduced = applyGroupEnvelope(state, envelope)
  68. expect(reduced.topicsByRoom['room-1']).toHaveLength(2)
  69. expect(reduced.topicsByRoom['room-1'].map(topic => topic.id)).toEqual(['topic-1', 'topic-2'])
  70. expect(reduced.topicsByRoom['room-1'][0]).toMatchObject({
  71. title: '新标题', preview: '最新内容', messageCount: 2, updatedAt: 5,
  72. })
  73. })
  74. it('routes v4 message events to their topic without polluting legacy room history', () => {
  75. const payload = groupMessage('server-topic', 'client-topic', '话题回复', 'completed', 'topic-1')
  76. const envelope: GroupSocketEnvelope = {
  77. type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'message.upsert', payload: payload as never,
  78. }
  79. const reduced = applyGroupEnvelope(initial(), envelope)
  80. expect(reduced.messagesByTopic['topic-1']).toEqual([expect.objectContaining({ id: 'server-topic', topicId: 'topic-1' })])
  81. expect(reduced.messagesByRoom['room-1']).toEqual([])
  82. })
  83. })