| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133 |
- // @vitest-environment node
- import { afterEach, beforeEach, expect, it, vi } from 'vitest'
- import { mkdtempSync, rmSync } from 'node:fs'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { randomUUID } from 'node:crypto'
- import { WorkspaceStore } from '../../src/server/workspaceStore'
- import type { WorkspaceConversation, WorkspaceMessage, WorkspaceTask } from '../../src/shared/workspace'
- let home: string, store: WorkspaceStore, direct: WorkspaceConversation, group: WorkspaceConversation
- const owner = 'owner'
- const current = (id: string) => store.require<WorkspaceConversation>(owner, 'conversation', id)
- const task = (id: string) => store.require<WorkspaceTask>(owner, 'conversation-task', id)
- function message(conversationId = direct.id, patch: Partial<WorkspaceMessage> = {}): WorkspaceMessage {
- return { id: randomUUID(), conversationId, seq: 0, role: 'assistant', content: '完成', reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: 1, ...patch }
- }
- beforeEach(() => {
- home = mkdtempSync(join(tmpdir(), 'yaoyao-unread-'))
- store = new WorkspaceStore(home)
- const first = store.createAgent(owner, { name: '甲', profile: 'default' })
- const second = store.createAgent(owner, { name: '乙', profile: 'default' })
- direct = store.list<WorkspaceConversation>(owner, 'conversation').find(c => c.memberIds[0] === first.id)!
- group = store.createGroup(owner, { name: '测试群', memberIds: [first.id, second.id], administratorId: first.id })
- })
- afterEach(() => { vi.restoreAllMocks(); store.close(); rmSync(home, { recursive: true, force: true }) })
- it('counts each completed reply and persists read state', () => {
- for (let i = 0; i < 12; i++) store.saveMessage(owner, message())
- expect(current(direct.id)).toMatchObject({ unread: true, unreadCount: 12 })
- const display = vi.spyOn(store, 'messageForDisplay')
- expect(store.conversationSummary(owner, current(direct.id)).unread).toBe(true)
- store.markConversationRead(owner, direct.id, current(direct.id).lastSeq, current(direct.id).unreadVersion)
- expect(display).not.toHaveBeenCalled()
- expect(current(direct.id)).toMatchObject({ unread: false, unreadCount: 0 })
- store.close(); store = new WorkspaceStore(home)
- expect(current(direct.id).unread).toBe(false)
- })
- it('ignores user, streaming, hidden and empty tool output; completion at the same message sequence is new attention', () => {
- 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))
- const streamed = message(direct.id, { status: 'streaming' })
- store.saveMessage(owner, streamed)
- expect(current(direct.id).unread).toBe(false)
- const snapshot = current(direct.id)
- store.markConversationRead(owner, direct.id, snapshot.lastSeq, snapshot.unreadVersion)
- streamed.status = 'complete'; store.saveMessage(owner, streamed)
- expect(current(direct.id)).toMatchObject({ lastSeq: snapshot.lastSeq, unread: true })
- store.markConversationRead(owner, direct.id, snapshot.lastSeq, snapshot.unreadVersion)
- expect(current(direct.id).unread).toBe(true)
- store.markConversationRead(owner, direct.id, streamed.seq, current(direct.id).unreadVersion)
- store.saveMessage(owner, streamed)
- expect(current(direct.id).unread).toBe(false)
- })
- it('keeps group attention until every unread task is read, without parsing messages', () => {
- const first = store.tasks(owner, group.id)[0]!
- const second = store.createTask(owner, group.id, {})
- store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
- store.saveMessage(owner, message(group.id, { conversationTaskId: second.id }))
- const display = vi.spyOn(store, 'messageForDisplay')
- store.markTaskRead(owner, group.id, first.id, task(first.id).lastSeq, task(first.id).unreadVersion)
- expect(task(first.id).unread).toBe(false)
- expect(current(group.id).unread).toBe(true)
- store.markTaskRead(owner, group.id, second.id, task(second.id).lastSeq, task(second.id).unreadVersion)
- expect(current(group.id).unread).toBe(false)
- expect(display).not.toHaveBeenCalled()
- expect(current(direct.id).unread).toBe(false)
- })
- it('does not let a stale task read clear another completion and recomputes flags after deletion', () => {
- const first = store.tasks(owner, group.id)[0]!
- store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
- const snapshot = task(first.id)
- store.saveMessage(owner, message(group.id, { conversationTaskId: first.id, status: 'complete', content: '新消息' }))
- store.markTaskRead(owner, group.id, first.id, snapshot.lastSeq, snapshot.unreadVersion)
- expect(task(first.id).unread).toBe(true)
- expect(() => store.markTaskRead('another-owner', group.id, first.id, 100, 1)).toThrow()
- expect(() => store.markTaskRead(owner, direct.id, first.id, 100, 1)).toThrow()
- store.deleteTask(owner, group.id, first.id)
- expect(current(group.id).unread).toBe(false)
- })
- it('migrates legacy counts once without loading message bodies and does not resurrect read flags', () => {
- const first = store.tasks(owner, group.id)[0]!
- store.saveMessage(owner, message())
- store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
- store.db.exec("UPDATE workspace_entities SET data=json_remove(data,'$.unread','$.unreadVersion') WHERE kind IN ('conversation','conversation-task')")
- store.db.prepare('DELETE FROM workspace_migrations WHERE id=?').run('workspace-unread-flag-v1')
- store.db.prepare('DELETE FROM workspace_migrations WHERE id=?').run('workspace-chat-unread-v2')
- store.close(); store = new WorkspaceStore(home)
- expect(current(direct.id)).toMatchObject({ unread: true, unreadCount: 1 })
- expect(current(group.id).unread).toBe(true)
- expect(task(first.id).unread).toBe(true)
- store.markConversationRead(owner, direct.id, current(direct.id).lastSeq, current(direct.id).unreadVersion)
- store.close(); store = new WorkspaceStore(home)
- expect(current(direct.id).unread).toBe(false)
- })
- it('reads only hidden IDs while preserving owner and task boundaries', () => {
- const first = store.tasks(owner, group.id)[0]!, second = store.createTask(owner, group.id, {})
- const hidden = message(group.id, { conversationTaskId: first.id, visible: false })
- store.saveMessage(owner, hidden)
- store.saveMessage(owner, message(group.id, { conversationTaskId: second.id, visible: false }))
- store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
- const display = vi.spyOn(store, 'messageForDisplay')
- expect(store.hiddenMessageIds(owner, group.id, first.id)).toEqual([hidden.id])
- expect(display).not.toHaveBeenCalled()
- expect(() => store.hiddenMessageIds('another-owner', group.id)).toThrow()
- 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)
- expect(plan.some(row => String(row.detail).includes('workspace_message_hidden'))).toBe(true)
- })
- it('snapshot IDs exclude future completions and keep gaps, retries and restart idempotent', () => {
- const a=message(), b=message(), streaming=message(direct.id,{status:'streaming'})
- for(const m of [a,b,streaming])store.saveMessage(owner,m)
- const snapshot=store.chatUnreadMessages(owner,direct.id).map(m=>({id:m.id,version:m.chatUnreadVersion}))
- streaming.status='complete';store.saveMessage(owner,streaming)
- store.markChatMessagesRead(owner,direct.id,[snapshot[1]!])
- expect(current(direct.id).unreadCount).toBe(2)
- store.markChatMessagesRead(owner,direct.id,snapshot)
- store.markChatMessagesRead(owner,direct.id,snapshot)
- expect(store.chatUnreadMessages(owner,direct.id).map(m=>m.id)).toEqual([streaming.id])
- store.close();store=new WorkspaceStore(home)
- expect(current(direct.id).unreadCount).toBe(1)
- store.saveMessage(owner,a)
- expect(current(direct.id).unreadCount).toBe(1)
- })
- it('excludes failure, interruption, reasoning and system; counts attachments once',()=>{
- for(const patch of [{status:'failed',error:'失败'},{status:'interrupted'},{role:'system'},{content:'<think>过程</think>'}])store.saveMessage(owner,message(direct.id,patch as Partial<WorkspaceMessage>))
- const attachment=message(direct.id,{content:'',attachments:[{id:'f',name:'结果.pdf',size:1,mime:'application/pdf'} as any]})
- store.saveMessage(owner,attachment);store.saveMessage(owner,attachment)
- expect(current(direct.id).unreadCount).toBe(1)
- })
|