workspaceUnread.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. // @vitest-environment node
  2. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { randomUUID } from 'node:crypto'
  7. import { WorkspaceStore } from '../../src/server/workspaceStore'
  8. import type { WorkspaceConversation, WorkspaceMessage, WorkspaceTask } from '../../src/shared/workspace'
  9. let home: string, store: WorkspaceStore, direct: WorkspaceConversation, group: WorkspaceConversation
  10. const owner = 'owner'
  11. const current = (id: string) => store.require<WorkspaceConversation>(owner, 'conversation', id)
  12. const task = (id: string) => store.require<WorkspaceTask>(owner, 'conversation-task', id)
  13. function message(conversationId = direct.id, patch: Partial<WorkspaceMessage> = {}): WorkspaceMessage {
  14. return { id: randomUUID(), conversationId, seq: 0, role: 'assistant', content: '完成', reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: 1, ...patch }
  15. }
  16. beforeEach(() => {
  17. home = mkdtempSync(join(tmpdir(), 'yaoyao-unread-'))
  18. store = new WorkspaceStore(home)
  19. const first = store.createAgent(owner, { name: '甲', profile: 'default' })
  20. const second = store.createAgent(owner, { name: '乙', profile: 'default' })
  21. direct = store.list<WorkspaceConversation>(owner, 'conversation').find(c => c.memberIds[0] === first.id)!
  22. group = store.createGroup(owner, { name: '测试群', memberIds: [first.id, second.id], administratorId: first.id })
  23. })
  24. afterEach(() => { vi.restoreAllMocks(); store.close(); rmSync(home, { recursive: true, force: true }) })
  25. it('counts each completed reply and persists read state', () => {
  26. for (let i = 0; i < 12; i++) store.saveMessage(owner, message())
  27. expect(current(direct.id)).toMatchObject({ unread: true, unreadCount: 12 })
  28. const display = vi.spyOn(store, 'messageForDisplay')
  29. expect(store.conversationSummary(owner, current(direct.id)).unread).toBe(true)
  30. store.markConversationRead(owner, direct.id, current(direct.id).lastSeq, current(direct.id).unreadVersion)
  31. expect(display).not.toHaveBeenCalled()
  32. expect(current(direct.id)).toMatchObject({ unread: false, unreadCount: 0 })
  33. store.close(); store = new WorkspaceStore(home)
  34. expect(current(direct.id).unread).toBe(false)
  35. })
  36. it('ignores user, streaming, hidden and empty tool output; completion at the same message sequence is new attention', () => {
  37. for (const patch of [{ role: 'user' as const }, { status: 'streaming' as const }, { visible: false }, { content: '', reasoning: '思考', tools: [{ name: 'read' }] }]) store.saveMessage(owner, message(direct.id, patch))
  38. const streamed = message(direct.id, { status: 'streaming' })
  39. store.saveMessage(owner, streamed)
  40. expect(current(direct.id).unread).toBe(false)
  41. const snapshot = current(direct.id)
  42. store.markConversationRead(owner, direct.id, snapshot.lastSeq, snapshot.unreadVersion)
  43. streamed.status = 'complete'; store.saveMessage(owner, streamed)
  44. expect(current(direct.id)).toMatchObject({ lastSeq: snapshot.lastSeq, unread: true })
  45. store.markConversationRead(owner, direct.id, snapshot.lastSeq, snapshot.unreadVersion)
  46. expect(current(direct.id).unread).toBe(true)
  47. store.markConversationRead(owner, direct.id, streamed.seq, current(direct.id).unreadVersion)
  48. store.saveMessage(owner, streamed)
  49. expect(current(direct.id).unread).toBe(false)
  50. })
  51. it('keeps group attention until every unread task is read, without parsing messages', () => {
  52. const first = store.tasks(owner, group.id)[0]!
  53. const second = store.createTask(owner, group.id, {})
  54. store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
  55. store.saveMessage(owner, message(group.id, { conversationTaskId: second.id }))
  56. const display = vi.spyOn(store, 'messageForDisplay')
  57. store.markTaskRead(owner, group.id, first.id, task(first.id).lastSeq, task(first.id).unreadVersion)
  58. expect(task(first.id).unread).toBe(false)
  59. expect(current(group.id).unread).toBe(true)
  60. store.markTaskRead(owner, group.id, second.id, task(second.id).lastSeq, task(second.id).unreadVersion)
  61. expect(current(group.id).unread).toBe(false)
  62. expect(display).not.toHaveBeenCalled()
  63. expect(current(direct.id).unread).toBe(false)
  64. })
  65. it('does not let a stale task read clear another completion and recomputes flags after deletion', () => {
  66. const first = store.tasks(owner, group.id)[0]!
  67. store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
  68. const snapshot = task(first.id)
  69. store.saveMessage(owner, message(group.id, { conversationTaskId: first.id, status: 'complete', content: '新消息' }))
  70. store.markTaskRead(owner, group.id, first.id, snapshot.lastSeq, snapshot.unreadVersion)
  71. expect(task(first.id).unread).toBe(true)
  72. expect(() => store.markTaskRead('another-owner', group.id, first.id, 100, 1)).toThrow()
  73. expect(() => store.markTaskRead(owner, direct.id, first.id, 100, 1)).toThrow()
  74. store.deleteTask(owner, group.id, first.id)
  75. expect(current(group.id).unread).toBe(false)
  76. })
  77. it('migrates legacy counts once without loading message bodies and does not resurrect read flags', () => {
  78. const first = store.tasks(owner, group.id)[0]!
  79. store.saveMessage(owner, message())
  80. store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
  81. store.db.exec("UPDATE workspace_entities SET data=json_remove(data,'$.unread','$.unreadVersion') WHERE kind IN ('conversation','conversation-task')")
  82. store.db.prepare('DELETE FROM workspace_migrations WHERE id=?').run('workspace-unread-flag-v1')
  83. store.db.prepare('DELETE FROM workspace_migrations WHERE id=?').run('workspace-chat-unread-v2')
  84. store.close(); store = new WorkspaceStore(home)
  85. expect(current(direct.id)).toMatchObject({ unread: true, unreadCount: 1 })
  86. expect(current(group.id).unread).toBe(true)
  87. expect(task(first.id).unread).toBe(true)
  88. store.markConversationRead(owner, direct.id, current(direct.id).lastSeq, current(direct.id).unreadVersion)
  89. store.close(); store = new WorkspaceStore(home)
  90. expect(current(direct.id).unread).toBe(false)
  91. })
  92. it('reads only hidden IDs while preserving owner and task boundaries', () => {
  93. const first = store.tasks(owner, group.id)[0]!, second = store.createTask(owner, group.id, {})
  94. const hidden = message(group.id, { conversationTaskId: first.id, visible: false })
  95. store.saveMessage(owner, hidden)
  96. store.saveMessage(owner, message(group.id, { conversationTaskId: second.id, visible: false }))
  97. store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
  98. const display = vi.spyOn(store, 'messageForDisplay')
  99. expect(store.hiddenMessageIds(owner, group.id, first.id)).toEqual([hidden.id])
  100. expect(display).not.toHaveBeenCalled()
  101. expect(() => store.hiddenMessageIds('another-owner', group.id)).toThrow()
  102. const plan = store.db.prepare("EXPLAIN QUERY PLAN SELECT id FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.visible')=0 AND json_extract(data,'$.conversationTaskId')=?").all(owner, group.id, first.id)
  103. expect(plan.some(row => String(row.detail).includes('workspace_message_hidden'))).toBe(true)
  104. })
  105. it('snapshot IDs exclude future completions and keep gaps, retries and restart idempotent', () => {
  106. const a=message(), b=message(), streaming=message(direct.id,{status:'streaming'})
  107. for(const m of [a,b,streaming])store.saveMessage(owner,m)
  108. const snapshot=store.chatUnreadMessages(owner,direct.id).map(m=>({id:m.id,version:m.chatUnreadVersion}))
  109. streaming.status='complete';store.saveMessage(owner,streaming)
  110. store.markChatMessagesRead(owner,direct.id,[snapshot[1]!])
  111. expect(current(direct.id).unreadCount).toBe(2)
  112. store.markChatMessagesRead(owner,direct.id,snapshot)
  113. store.markChatMessagesRead(owner,direct.id,snapshot)
  114. expect(store.chatUnreadMessages(owner,direct.id).map(m=>m.id)).toEqual([streaming.id])
  115. store.close();store=new WorkspaceStore(home)
  116. expect(current(direct.id).unreadCount).toBe(1)
  117. store.saveMessage(owner,a)
  118. expect(current(direct.id).unreadCount).toBe(1)
  119. })
  120. it('excludes failure, interruption, reasoning and system; counts attachments once',()=>{
  121. for(const patch of [{status:'failed',error:'失败'},{status:'interrupted'},{role:'system'},{content:'<think>过程</think>'}])store.saveMessage(owner,message(direct.id,patch as Partial<WorkspaceMessage>))
  122. const attachment=message(direct.id,{content:'',attachments:[{id:'f',name:'结果.pdf',size:1,mime:'application/pdf'} as any]})
  123. store.saveMessage(owner,attachment);store.saveMessage(owner,attachment)
  124. expect(current(direct.id).unreadCount).toBe(1)
  125. })