groupProtocol.test.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import { describe, expect, it } from 'vitest'
  2. import {
  3. isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL,
  4. type GroupAgent, type GroupMessage, type GroupSocketEnvelope,
  5. } from '@shared/types'
  6. import {
  7. applyGroupEnvelope, classifyGroupEnvelope, convergeGroupAgents, type GroupProtocolState, upsertGroupMessage,
  8. } from '@/utils/groupProtocol'
  9. import { normalizeGroupAgent } from '@/utils/normalize'
  10. const epoch = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
  11. function groupMessage(
  12. id: string,
  13. clientMessageId: string,
  14. content: string,
  15. status: GroupMessage['status'],
  16. topicId: string | null = null,
  17. ): GroupMessage {
  18. return {
  19. seq: status === 'queued' ? 0 : 1, id, roomId: 'room-1', topicId, senderKind: 'human', senderId: 'me', senderName: '我',
  20. rootMessageId: id, clientMessageId, content, reasoning: '', toolState: [], status, error: '', createdAt: 1, updatedAt: 1,
  21. }
  22. }
  23. function initial(): GroupProtocolState {
  24. return {
  25. epoch, cursor: 8,
  26. rooms: [{ id: 'room-1', name: '群聊', cwd: '', createdAt: 1, updatedAt: 1, archived: false, agentCount: 1, unreadCount: 0, maxReplyRounds: 3 }],
  27. roomDetails: {}, topicsByRoom: { 'room-1': [] }, messagesByRoom: { 'room-1': [] }, messagesByTopic: {},
  28. }
  29. }
  30. function groupAgent(id: string, displayName: string, isHost: boolean): GroupAgent {
  31. return {
  32. id, roomId: 'room-1', profile: displayName, displayName, description: '', storedSessionId: null,
  33. lastContextMessageSeq: 0, enabled: true, replyWithoutMention: false, isHost,
  34. model: null, provider: null, reasoningEffort: null, fastMode: null,
  35. createdAt: 1, updatedAt: 1, status: 'idle',
  36. }
  37. }
  38. describe('group protocol continuity', () => {
  39. it('accepts only exact ready and next cursor, ignoring duplicates', () => {
  40. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.ready', epoch, cursor: 8 })).toBe('ready')
  41. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 8 })).toBe('ignore')
  42. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 9 })).toBe('apply')
  43. })
  44. it('requires reset for gaps, epoch changes, and server reset', () => {
  45. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 10 })).toBe('reset')
  46. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch: 'other', cursor: 9 })).toBe('reset')
  47. expect(classifyGroupEnvelope(epoch, 8, { type: 'group.reset_required', epoch, cursor: 8 })).toBe('reset')
  48. })
  49. it('reconciles optimistic group messages by clientMessageId', () => {
  50. const optimistic = groupMessage('local', 'client-1', 'hello', 'queued')
  51. const server = groupMessage('server', 'client-1', 'hello', 'completed')
  52. const merged = upsertGroupMessage([optimistic], server)
  53. expect(merged).toHaveLength(1)
  54. expect(merged[0]).toMatchObject({ id: 'server', status: 'completed' })
  55. })
  56. it('applies message events to their own room and advances the cursor', () => {
  57. const payload = groupMessage('server', 'client-1', 'answer', 'completed')
  58. const envelope: GroupSocketEnvelope = {
  59. type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'message.upsert', payload: payload as never,
  60. }
  61. const reduced = applyGroupEnvelope(initial(), envelope)
  62. expect(reduced.cursor).toBe(9)
  63. expect(reduced.messagesByRoom['room-1']).toHaveLength(1)
  64. expect(reduced.rooms[0].lastMessage?.id).toBe('server')
  65. })
  66. it('upserts topic.updated events in most-recently-active order', () => {
  67. const state = initial()
  68. state.topicsByRoom['room-1'] = [
  69. { id: 'topic-1', roomId: 'room-1', title: '旧标题', preview: '旧内容', messageCount: 1, createdAt: 1, updatedAt: 2 },
  70. { id: 'topic-2', roomId: 'room-1', title: '另一话题', preview: '另一内容', messageCount: 1, createdAt: 1, updatedAt: 4 },
  71. ]
  72. const envelope: GroupSocketEnvelope = {
  73. type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'topic.updated',
  74. payload: {
  75. id: 'topic-1', roomId: 'room-1', title: '新标题', preview: '最新内容',
  76. messageCount: 2, createdAt: 1, updatedAt: 5,
  77. },
  78. }
  79. const reduced = applyGroupEnvelope(state, envelope)
  80. expect(reduced.topicsByRoom['room-1']).toHaveLength(2)
  81. expect(reduced.topicsByRoom['room-1'].map(topic => topic.id)).toEqual(['topic-1', 'topic-2'])
  82. expect(reduced.topicsByRoom['room-1'][0]).toMatchObject({
  83. title: '新标题', preview: '最新内容', messageCount: 2, updatedAt: 5,
  84. })
  85. })
  86. it('routes v4 message events to their topic without polluting legacy room history', () => {
  87. const payload = groupMessage('server-topic', 'client-topic', '话题回复', 'completed', 'topic-1')
  88. const envelope: GroupSocketEnvelope = {
  89. type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'message.upsert', payload: payload as never,
  90. }
  91. const reduced = applyGroupEnvelope(initial(), envelope)
  92. expect(reduced.messagesByTopic['topic-1']).toEqual([expect.objectContaining({ id: 'server-topic', topicId: 'topic-1' })])
  93. expect(reduced.messagesByRoom['room-1']).toEqual([])
  94. })
  95. it('applies v8 room activity without replacing the room identity', () => {
  96. const activityMessage = groupMessage('activity-message', 'activity-client', '新的回复', 'completed', 'topic-1')
  97. const reduced = applyGroupEnvelope(initial(), {
  98. type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'room.activity',
  99. payload: { roomId: 'room-1', activeRunCount: 2, unreadCount: 3, lastMessage: activityMessage as never },
  100. })
  101. expect(reduced.rooms[0]).toMatchObject({
  102. id: 'room-1', name: '群聊', activeRunCount: 2, unreadCount: 3,
  103. lastMessage: { id: 'activity-message' },
  104. })
  105. })
  106. it('converges immediately to one host when agent.updated promotes a new host', () => {
  107. const state = initial()
  108. const first = groupAgent('agent-1', '夭夭', true)
  109. const second = groupAgent('agent-2', '瑶儿', false)
  110. state.roomDetails['room-1'] = {
  111. id: 'room-1', name: '群聊', cwd: '', createdAt: 1, updatedAt: 1, archived: false,
  112. maxReplyRounds: 3, agents: [first, second], runs: [], pendingInteractions: [], latestCursor: 8,
  113. }
  114. const promoted = applyGroupEnvelope(state, {
  115. type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'agent.updated',
  116. payload: { ...second, isHost: true, updatedAt: 2 },
  117. })
  118. expect(promoted.roomDetails['room-1'].agents.map(agent => [agent.id, agent.isHost])).toEqual([
  119. ['agent-1', false], ['agent-2', true],
  120. ])
  121. const demoted = applyGroupEnvelope(promoted, {
  122. type: 'group.event', epoch, cursor: 10, roomId: 'room-1', event: 'agent.updated',
  123. payload: { ...first, isHost: false, updatedAt: 2 },
  124. })
  125. expect(demoted.roomDetails['room-1'].agents.filter(agent => agent.isHost).map(agent => agent.id)).toEqual(['agent-2'])
  126. })
  127. it('ignores an older REST host response after a newer host event', () => {
  128. const state = initial()
  129. const former = groupAgent('agent-1', '夭夭', false)
  130. former.updatedAt = 3
  131. const current = groupAgent('agent-2', '瑶儿', true)
  132. current.updatedAt = 3
  133. state.roomDetails['room-1'] = {
  134. id: 'room-1', name: '群聊', cwd: '', createdAt: 1, updatedAt: 1, archived: false,
  135. maxReplyRounds: 3, agents: [former, current], runs: [], pendingInteractions: [], latestCursor: 8,
  136. }
  137. const stale = { ...former, isHost: true, updatedAt: 2 }
  138. state.roomDetails['room-1'].agents = convergeGroupAgents(state.roomDetails['room-1'].agents, stale)
  139. expect(state.roomDetails['room-1'].agents.filter(agent => agent.isHost).map(agent => agent.id)).toEqual(['agent-2'])
  140. expect(state.roomDetails['room-1'].agents.find(agent => agent.id === 'agent-1')?.updatedAt).toBe(3)
  141. })
  142. it('normalizes v5 host fields while keeping v4 agents non-host by default', () => {
  143. expect(normalizeGroupAgent({ id: 'legacy', room_id: 'room-1', profile: 'legacy' }).isHost).toBe(false)
  144. expect(normalizeGroupAgent({ id: 'host', room_id: 'room-1', profile: 'host', is_host: true }).isHost).toBe(true)
  145. expect(isSupportedGroupProtocolVersion(8)).toBe(true)
  146. expect(isSupportedGroupProtocolVersion(10)).toBe(true)
  147. expect(isSupportedGroupProtocolVersion(11)).toBe(false)
  148. expect(SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL).toBe('v2–v10')
  149. })
  150. })