chatUnread.test.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. // @vitest-environment node
  2. import { mkdtempSync, rmSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { ChatCacheStore } from '../../src/server/chatCache.js'
  7. import { isFinalChatResult } from '../../src/server/chatUnread.js'
  8. const stores: ChatCacheStore[] = [], homes: string[] = []
  9. function setup() {
  10. const home = mkdtempSync(join(tmpdir(), 'chat-unread-')); homes.push(home)
  11. const store = new ChatCacheStore(home); stores.push(store)
  12. store.recordCommand('owner', 'default', 'chat', 'session.create', {})
  13. return { store, home }
  14. }
  15. function snapshot(store: ChatCacheStore, messages: object[], total = messages.length) {
  16. store.putSnapshot('owner', 'messages', 'messages', 'default', 'chat', {
  17. status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify({
  18. messages, pagination: { total, offset: 0 },
  19. })),
  20. })
  21. }
  22. afterEach(() => {
  23. for (const store of stores.splice(0)) if (!store.isClosed) store.close()
  24. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  25. })
  26. describe('ordinary conversation list unread results', () => {
  27. it('counts final history replies while preserving the existing counters and raw read positions', () => {
  28. const { store } = setup()
  29. const messages = [
  30. { id: 'u', role: 'user', content: '问题' },
  31. { id: 'call', role: 'assistant', content: '先查一下', tool_calls: JSON.stringify([{ id: 'tool' }]) },
  32. { id: 'tool', role: 'tool', content: '查询结果' },
  33. { id: 'reasoning', role: 'assistant', content: '<think>思考</think>' },
  34. { id: 'interim', role: 'assistant', content: '继续处理', final_result: false, status: 'complete' },
  35. { id: 'final', role: 'assistant', content: '最终结果' },
  36. { id: 'user2', role: 'user', content: '再问' },
  37. { id: 'final2', role: 'assistant', content: '第二个结果', tool_calls: '[]' },
  38. ]
  39. snapshot(store, messages)
  40. expect(store.unread('owner', 'default')).toMatchObject({ total_unread: 8, sessions: [
  41. { unread_count: 8, message_count: 8, read_message_count: 0, final_unread_count: 2 },
  42. ] })
  43. expect(store.markRead('owner', 'default', 'chat', 4)).toMatchObject({ read_message_count: 4, final_unread_count: 2 })
  44. expect(store.markRead('owner', 'default', 'chat', 6)).toMatchObject({ read_message_count: 6, final_unread_count: 1 })
  45. const list = JSON.parse(store.localList('owner', 'default', {}).body.toString())
  46. expect(list.sessions[0]).toMatchObject({ message_count: 8, final_unread_count: 1 })
  47. snapshot(store, messages)
  48. expect(store.markRead('owner', 'default', 'chat', 0).final_unread_count).toBe(1)
  49. expect(store.unread('other', 'default').sessions).toEqual([])
  50. expect(store.unread('owner', 'other').sessions).toEqual([])
  51. expect(store.markRead('owner', 'default', 'chat', 999).final_unread_count).toBe(0)
  52. })
  53. it('does not count tools, progress, or an unfinished answer, then counts completion once across restart', () => {
  54. const { store, home } = setup()
  55. let seq = 0
  56. const event = (type: string, payload: object = {}) => store.recordEvent('owner', 'default', 'chat', {type, payload, delivery_id: String(++seq)})
  57. event('message.start', { message_id: 'a' })
  58. event('message.delta', { text: '处理中' })
  59. event('message.interim')
  60. event('tool.start', { tool_id: 't', name: 'search' })
  61. event('tool.complete', { tool_id: 't', result: 'found' })
  62. expect(store.unread('owner', 'default').sessions[0].final_unread_count).toBe(0)
  63. store.markRead('owner', 'default', 'chat')
  64. event('message.complete', { text: '最终结果' })
  65. event('run.completed')
  66. expect(store.unread('owner', 'default').sessions[0]).toMatchObject({ unread_count: 0, final_unread_count: 1 })
  67. store.close()
  68. const restored = new ChatCacheStore(home); stores.push(restored)
  69. expect(restored.unread('owner', 'default').sessions[0].final_unread_count).toBe(1)
  70. expect(restored.markRead('owner', 'default', 'chat').final_unread_count).toBe(0)
  71. })
  72. it('uses existing read cursors for old caches and does not guess from missing history rows', () => {
  73. const { store } = setup()
  74. snapshot(store, [{ id: 'a', role: 'assistant', content: '结果' }], 20)
  75. store.db.prepare('UPDATE chat_local_state SET read_count=19, final_read_count=NULL').run()
  76. expect(store.unread('owner', 'default').sessions[0].final_unread_count).toBe(1)
  77. store.db.prepare('UPDATE chat_local_state SET read_count=20').run()
  78. expect(store.unread('owner', 'default').sessions[0].final_unread_count).toBe(0)
  79. })
  80. it('does not let an abandoned streaming row prevent later results from being marked read', () => {
  81. const { store } = setup()
  82. store.recordEvent('owner', 'default', 'chat', {type:'message.delta',payload:{text:'旧回复'},delivery_id:'old'})
  83. store.recordCommand('owner', 'default', 'chat', 'prompt.submit', {text:'新问题',_delivery_id:'new'})
  84. store.recordEvent('owner', 'default', 'chat', {type:'message.complete',payload:{text:'新结果'},delivery_id:'done'})
  85. expect(store.unread('owner', 'default').sessions[0].final_unread_count).toBe(1)
  86. expect(store.markRead('owner', 'default', 'chat').final_unread_count).toBe(0)
  87. })
  88. it('recognizes attachment-only results and ignores failed or reasoning-only outputs', () => {
  89. expect(isFinalChatResult({ role: 'assistant', attachments: [{ path: 'result.pdf' }] })).toBe(true)
  90. expect(isFinalChatResult({ role: 'assistant', content: [{ type: 'text', text: '结果' }] })).toBe(true)
  91. for (const message of [
  92. { role: 'system', content: 'notice' },
  93. { role: 'assistant', reasoning: '思考', content: '' },
  94. { role: 'assistant', content: '部分结果', status: 'failed' },
  95. { role: 'assistant', content: '处理中', status: 'streaming' },
  96. { role: 'assistant', content: [{ type: 'tool_use', id: 't' }] },
  97. { role: 'assistant', content: [{ type: 'text', text: '查询中' }, { type: 'tool_use', id: 't' }] },
  98. ]) expect(isFinalChatResult(message)).toBe(false)
  99. })
  100. })