groupProtocol.test.ts 2.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
  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(id: string, clientMessageId: string, content: string, status: GroupMessage['status']): GroupMessage {
  8. return {
  9. seq: status === 'queued' ? 0 : 1, id, roomId: 'room-1', senderKind: 'human', senderId: 'me', senderName: '我',
  10. rootMessageId: id, clientMessageId, content, reasoning: '', toolState: [], status, error: '', createdAt: 1, updatedAt: 1,
  11. }
  12. }
  13. function initial(): GroupProtocolState {
  14. return {
  15. epoch, cursor: 8,
  16. rooms: [{ id: 'room-1', name: '群聊', cwd: '', createdAt: 1, updatedAt: 1, archived: false, agentCount: 1, unreadCount: 0, maxReplyRounds: 3 }],
  17. roomDetails: {}, messagesByRoom: { 'room-1': [] },
  18. }
  19. }
  20. describe('group protocol v2 continuity', () => {
  21. it('accepts only exact ready and next cursor, ignoring duplicates', () => {
  22. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.ready', epoch, cursor: 8 })).toBe('ready')
  23. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 8 })).toBe('ignore')
  24. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 9 })).toBe('apply')
  25. })
  26. it('requires reset for gaps, epoch changes, and server reset', () => {
  27. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 10 })).toBe('reset')
  28. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch: 'other', cursor: 9 })).toBe('reset')
  29. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.reset_required', epoch, cursor: 8 })).toBe('reset')
  30. })
  31. it('reconciles optimistic group messages by clientMessageId', () => {
  32. const optimistic = groupMessage('local', 'client-1', 'hello', 'queued')
  33. const server = groupMessage('server', 'client-1', 'hello', 'completed')
  34. const merged = upsertGroupMessage([optimistic], server)
  35. expect(merged).toHaveLength(1)
  36. expect(merged[0]).toMatchObject({ id: 'server', status: 'completed' })
  37. })
  38. it('applies message events to their own room and advances the cursor', () => {
  39. const payload = groupMessage('server', 'client-1', 'answer', 'completed')
  40. const envelope: GroupSocketEnvelope = {
  41. type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'message.upsert', payload: payload as never,
  42. }
  43. const reduced = applyGroupEnvelope(initial(), envelope)
  44. expect(reduced.cursor).toBe(9)
  45. expect(reduced.messagesByRoom['room-1']).toHaveLength(1)
  46. expect(reduced.rooms[0].lastMessage?.id).toBe('server')
  47. })
  48. })