openVikingSessionSync.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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 { OpenVikingError } from '@openviking/sdk'
  8. import { WorkspaceStore } from '../../src/server/workspaceStore'
  9. import { OpenVikingService } from '../../src/server/openVikingService'
  10. import { OpenVikingConfigurationManager } from '../../src/server/openVikingConfiguration'
  11. import { OpenVikingSessionSync } from '../../src/server/openVikingSessionSync'
  12. import type { WorkspaceAgent, WorkspaceConversation, WorkspaceMessage } from '../../src/shared/workspace'
  13. let home: string, store: WorkspaceStore, service: OpenVikingService, sync: OpenVikingSessionSync, agent: WorkspaceAgent, conversation: WorkspaceConversation
  14. const remote = new Map<string, any[]>(), clients: any[] = []
  15. let failAfterWrite = false, offline = false, autoCommit = false
  16. let partialWrite: number | undefined, silentPartial = false
  17. const notFound = () => new OpenVikingError('not found', { code: 'NOT_FOUND', statusCode: 404 })
  18. const config = { enabled: true, url: 'http://localhost:1933', accountId: 'test', adminKey: 'admin-secret' }
  19. beforeEach(async () => {
  20. home = mkdtempSync(join(tmpdir(), 'yaoyao-session-sync-')); store = new WorkspaceStore(home)
  21. const manager = new OpenVikingConfigurationManager(home, undefined, async () => {})
  22. await manager.update(config)
  23. service = new OpenVikingService(store, manager, identity => {
  24. const prefix = `${identity.baseUrl}:${identity.account}:${identity.user}:`
  25. const client = {
  26. adminRegisterUser: vi.fn(async () => ({ user_key: randomUUID() })),
  27. adminRemoveUser: vi.fn(async (_account: string, user: string) => { for (const key of remote.keys()) if (key.includes(`:${user}:`)) remote.delete(key) }),
  28. createSession: vi.fn(async ({ sessionId }: any) => { if (!remote.has(prefix + sessionId)) remote.set(prefix + sessionId, []); return { session_id: sessionId } }),
  29. getSession: vi.fn(async (id: string) => { if (offline) throw new Error('offline'); if (!remote.has(prefix + id)) throw notFound(); return { uri: id, message_count: remote.get(prefix + id)!.length, auto_commit_policy: autoCommit ? { message_count_threshold: 1 } : null } }),
  30. read: vi.fn(async (uri: string) => remote.get(prefix + uri.replace('/messages.jsonl', ''))!.map(row => JSON.stringify({ role: row.role, parts: [{ type: 'text', text: row.content }], created_at: row.createdAt })).join('\n')),
  31. batchAddMessages: vi.fn(async (id: string, messages: any[]) => { remote.get(prefix + id)!.push(...messages.slice(0, partialWrite ?? messages.length)); if (partialWrite !== undefined && !silentPartial) { partialWrite = undefined; throw new Error('partial write') } partialWrite = undefined; if (failAfterWrite) { failAfterWrite = false; throw new Error('response lost') } return {} }),
  32. deleteSession: vi.fn(async (id: string) => { remote.delete(prefix + id) }),
  33. commitSession: vi.fn(),
  34. }
  35. clients.push(client); return client as any
  36. })
  37. vi.spyOn(service, 'disableSessionAutoCommit').mockImplementation(async () => { autoCommit = false })
  38. agent = store.createAgent('owner', { name: 'Bot', profile: 'default' })
  39. conversation = store.list<WorkspaceConversation>('owner', 'conversation')[0]!
  40. sync = new OpenVikingSessionSync(store, service)
  41. })
  42. afterEach(() => { sync.close(); store.close(); rmSync(home, { recursive: true, force: true }); remote.clear(); clients.length = 0; offline = false; failAfterWrite = false; autoCommit = false; partialWrite = undefined; silentPartial = false; vi.restoreAllMocks() })
  43. function message(role: 'user' | 'assistant' | 'system', content: string, fields: Partial<WorkspaceMessage> = {}) {
  44. const value: WorkspaceMessage = { id: randomUUID(), conversationId: conversation.id, seq: 0, role, ...(role === 'assistant' ? { agentId: agent.id } : {}), content, reasoning: 'private thought', status: 'complete', attachments: [], tools: [{ secret: 'private tool' }], createdAt: Date.now(), ...fields }
  45. store.saveMessage('owner', value); return value
  46. }
  47. function unthrottle() { store.db.prepare('UPDATE openviking_sync_jobs SET next_at=0').run() }
  48. async function drain(times = 6) { for (let i = 0; i < times; i++) { unthrottle(); await sync.tick() } }
  49. const texts = () => [...remote.values()].flat().map(row => row.content)
  50. it('backfills archived history in pages and only uploads final visible transcript text', async () => {
  51. for (let i = 0; i < 260; i++) message(i % 2 ? 'assistant' : 'user', `message ${i}`)
  52. message('assistant', 'hidden', { visible: false })
  53. store.put('owner', 'conversation', conversation.id, { ...store.require('owner', 'conversation', conversation.id), archived: true })
  54. await sync.tick()
  55. expect(remote.size).toBe(0)
  56. await drain()
  57. expect(texts()).toHaveLength(260)
  58. expect(texts()[259]).toBe('message 259')
  59. expect(JSON.stringify([...remote.values()])).not.toMatch(/private thought|private tool|hidden/)
  60. expect(sync.status()).toMatchObject({ backfilling: false, complete: 1, pending: 0 })
  61. expect(clients.every(client => client.commitSession.mock.calls.length === 0)).toBe(true)
  62. })
  63. it('does not duplicate messages when an append succeeds remotely but the response is lost, including after restart', async () => {
  64. message('user', 'hello'); message('assistant', 'answer'); failAfterWrite = true
  65. await sync.tick()
  66. expect(texts()).toEqual(['hello', 'answer'])
  67. sync.close(); sync = new OpenVikingSessionSync(store, service)
  68. await drain()
  69. expect(texts()).toEqual(['hello', 'answer'])
  70. expect(sync.status().complete).toBe(1)
  71. })
  72. it('waits for streaming replies, preserves interrupted text, and catches up after disabling', async () => {
  73. message('user', 'first')
  74. const reply = message('assistant', 'partial', { status: 'streaming' })
  75. message('user', 'later')
  76. await sync.tick(); expect(texts()).toEqual(['first'])
  77. service.configure(undefined)
  78. store.saveMessage('owner', { ...reply, content: 'partial final', status: 'interrupted' })
  79. await sync.tick(); expect(texts()).toEqual(['first'])
  80. service.configure(config); await drain()
  81. expect(texts()).toEqual(['first', 'partial final', 'later'])
  82. })
  83. it('stops after three failures and retries on request without blocking local chat writes', async () => {
  84. message('user', 'hello'); offline = true
  85. await drain()
  86. expect(sync.status()).toMatchObject({ failed: 1, lastError: 'offline' })
  87. message('user', 'still chatting'); await sync.tick()
  88. expect(sync.status().failed).toBe(1)
  89. offline = false; sync.retry(); await drain()
  90. expect(texts()).toEqual(['hello', 'still chatting'])
  91. expect(sync.status()).toMatchObject({ failed: 0, complete: 1 })
  92. })
  93. it('keeps Bot namespaces separate and does not create sessions for temporary helpers', async () => {
  94. message('assistant', 'first bot')
  95. const second = store.createAgent('owner', { name: 'Second', profile: 'default' })
  96. const secondConversation = store.list<WorkspaceConversation>('owner', 'conversation').find(row => row.memberIds[0] === second.id)!
  97. message('assistant', 'second bot', { conversationId: secondConversation.id, agentId: second.id })
  98. const helper = store.createAgent('owner', { name: 'Temporary', profile: 'default' })
  99. store.put('owner', 'agent', helper.id, { ...helper, temporaryGoalId: randomUUID() })
  100. message('assistant', 'helper secret', { agentId: helper.id })
  101. await drain()
  102. expect(remote.size).toBe(2)
  103. expect([...remote.values()].every(rows => rows.length === 1)).toBe(true)
  104. expect(texts()).not.toContain('helper secret')
  105. service.configure({ ...config, accountId: 'new-account' }); await drain()
  106. expect(remote.size).toBe(4)
  107. })
  108. it('cleans up deleted conversations and cannot resurrect them on restart', async () => {
  109. message('user', 'hello'); await drain(); expect(remote.size).toBe(1)
  110. store.remove('owner', 'conversation', conversation.id)
  111. store.event('owner', 'conversation.deleted', { id: conversation.id }, conversation.id)
  112. await drain(); expect(remote.size).toBe(0)
  113. sync.close(); sync = new OpenVikingSessionSync(store, service); await drain()
  114. expect(remote.size).toBe(0)
  115. })
  116. it('disables inherited native auto-commit and preserves the paused automatic-memory preference', async () => {
  117. autoCommit = true; store.put('owner', 'agent', agent.id, { ...agent, memoryEnabled: false })
  118. message('user', 'hello'); await drain()
  119. expect(service.disableSessionAutoCommit).toHaveBeenCalledTimes(1)
  120. expect(texts()).toEqual(['hello'])
  121. expect(store.require('owner', 'agent', agent.id).memoryEnabled).toBe(false)
  122. })
  123. it('keeps group topics and participating Bots isolated, including their triggering user message', async () => {
  124. const second = store.createAgent('owner', { name: 'Second', profile: 'default' })
  125. const groupId = randomUUID()
  126. store.put('owner', 'conversation', groupId, { ...conversation, id: groupId, kind: 'group', memberIds: [agent.id, second.id], lastSeq: 0 })
  127. const taskIds = [randomUUID(), randomUUID()]
  128. for (const [index, taskId] of taskIds.entries()) {
  129. store.put('owner', 'conversation-task', taskId, { id: taskId, conversationId: groupId, memberIds: [agent.id, second.id], lastSeq: 0 })
  130. const source = message('user', `topic ${index}`, { conversationId: groupId, conversationTaskId: taskId })
  131. const runId = randomUUID(); store.put('owner', 'run', runId, { id: runId, messageId: source.id })
  132. message('assistant', `reply ${index}`, { conversationId: groupId, conversationTaskId: taskId, agentId: index ? second.id : agent.id, runId })
  133. }
  134. await drain()
  135. expect([...remote.values()].map(rows => rows.map(row => row.content))).toEqual([['topic 0', 'reply 0'], ['topic 1', 'reply 1']])
  136. store.remove('owner', 'conversation-task', taskIds[0]!)
  137. await drain(); expect([...remote.values()].map(rows => rows.map(row => row.content))).toEqual([['topic 1', 'reply 1']])
  138. })
  139. it('reconciles retained messages on restart even if the event log was pruned', async () => {
  140. message('user', 'before'); await drain(); sync.close()
  141. message('assistant', 'offline history')
  142. store.db.prepare('DELETE FROM workspace_events').run()
  143. sync = new OpenVikingSessionSync(store, service); await drain()
  144. expect(texts()).toEqual(['before', 'offline history'])
  145. })
  146. it('keeps attachment metadata without binary data and deletes removed Bots', async () => {
  147. message('user', 'attached', { attachments: [{ id: randomUUID(), name: 'proof.bin', mimeType: 'application/octet-stream', size: 15, sender: 'user', path: '/private/proof.bin', data: 'secret-binary' } as any] })
  148. await drain()
  149. expect(texts()[0]).toContain('proof.bin'); expect(texts()[0]).toContain('15')
  150. expect(texts()[0]).not.toMatch(/secret-binary|\/private/)
  151. store.remove('owner', 'agent', agent.id); await drain()
  152. expect(remote.size).toBe(0)
  153. })
  154. it('omits inline reasoning and finishes deletion queued while OpenViking was disabled', async () => {
  155. message('assistant', '<think>private thought</think>visible answer'); await drain()
  156. expect(texts()).toEqual(['visible answer'])
  157. service.configure(undefined); await sync.tick(); await service.removeUser('owner', agent)
  158. store.remove('owner', 'agent', agent.id)
  159. service.configure(config); await drain()
  160. expect(remote.size).toBe(0)
  161. expect(service.binding('owner', agent.id)).toMatchObject({ status: 'removed', pendingRemoval: false })
  162. })
  163. it('mirrors Bot communication bodies with sender and receiver roles, without internal display wrappers', async () => {
  164. const receiver = store.createAgent('owner', { name: 'Receiver', profile: 'default' })
  165. const destination = store.list<WorkspaceConversation>('owner', 'conversation').find(row => row.memberIds[0] === receiver.id)!
  166. const peerId = randomUUID()
  167. store.put('owner', 'peer-message', peerId, { id: peerId, fromAgentId: agent.id, toAgentId: receiver.id, originConversationId: conversation.id, conversationId: destination.id, content: 'collaboration body', fileIds: [] })
  168. message('system', 'internal wrapper', { peerMessageId: peerId })
  169. message('system', 'internal wrapper', { conversationId: destination.id, peerMessageId: peerId, runId: randomUUID() })
  170. await drain()
  171. expect([...remote.values()].flat().map(row => [row.role, row.content]).sort()).toEqual([['assistant', 'collaboration body'], ['user', 'collaboration body']])
  172. })
  173. it('catches messages from another client while an append is in flight', async () => {
  174. message('user', 'client A')
  175. await drain()
  176. message('user', 'client B')
  177. const client = clients.find(row => row.batchAddMessages.mock.calls.length)!
  178. const append = client.batchAddMessages.getMockImplementation()!
  179. client.batchAddMessages.mockImplementationOnce(async (id: string, batch: any[]) => {
  180. const result = await append(id, batch)
  181. message('user', 'client A while B appends')
  182. return result
  183. })
  184. await drain()
  185. expect(texts()).toEqual(['client A', 'client B', 'client A while B appends'])
  186. expect(sync.status()).toMatchObject({ complete: 1, pending: 0, failed: 0 })
  187. })
  188. it('serializes overlapping ticks from different sync instances sharing the same database', async () => {
  189. message('user', 'one copy')
  190. const other = new OpenVikingSessionSync(store, service)
  191. try {
  192. await Promise.all([sync.tick(), other.tick()])
  193. await drain()
  194. expect(texts()).toEqual(['one copy'])
  195. expect(sync.status().complete).toBe(1)
  196. } finally { other.close() }
  197. })
  198. it('recovers a partial failed append after restart without repeating the accepted prefix', async () => {
  199. message('user', 'first'); message('assistant', 'second'); message('user', 'third')
  200. partialWrite = 1
  201. await sync.tick()
  202. expect(texts()).toEqual(['first'])
  203. sync.close(); sync = new OpenVikingSessionSync(store, service)
  204. await drain()
  205. expect(texts()).toEqual(['first', 'second', 'third'])
  206. expect(sync.status()).toMatchObject({ complete: 1, failed: 0 })
  207. })
  208. it('verifies writes even when a server acknowledges a partial batch as successful', async () => {
  209. message('user', 'first'); message('assistant', 'second'); message('user', 'third')
  210. partialWrite = 1; silentPartial = true
  211. await drain()
  212. expect(texts()).toEqual(['first', 'second', 'third'])
  213. expect(sync.status()).toMatchObject({ complete: 1, failed: 0 })
  214. })
  215. it('detects a local revision received during the remote read before appending a suffix', async () => {
  216. const original = message('user', 'original'); await drain()
  217. message('assistant', 'must not append')
  218. const client = clients.find(row => row.batchAddMessages.mock.calls.length)!
  219. const read = client.read.getMockImplementation()!
  220. client.read.mockImplementationOnce(async (uri: string) => {
  221. const raw = await read(uri)
  222. store.saveMessage('owner', { ...original, content: 'changed on another client' })
  223. return raw
  224. })
  225. await drain()
  226. expect(texts()).toEqual(['original'])
  227. expect(sync.status()).toMatchObject({ failed: 1 })
  228. expect(sync.status().lastError).toContain('已停止追加')
  229. })
  230. it('recovers a legacy failed job only after verifying the remote prefix on startup', async () => {
  231. message('user', 'prefix'); await drain()
  232. message('assistant', 'suffix')
  233. store.db.prepare("UPDATE openviking_sync_jobs SET status='failed',attempts=3,error=?").run('OpenViking 会话记录与本地顺序不一致,已停止追加,请核对会话')
  234. sync.close(); sync = new OpenVikingSessionSync(store, service)
  235. await drain()
  236. expect(texts()).toEqual(['prefix', 'suffix'])
  237. expect(sync.status()).toMatchObject({ failed: 0, complete: 1 })
  238. })
  239. it.each(['reordered', 'changed', 'extra'])('keeps a real %s remote conflict closed across restart and explicit retry', async kind => {
  240. message('user', 'first', { createdAt: 1000 }); message('assistant', 'second', { createdAt: 2000 })
  241. await drain()
  242. const rows = [...remote.values()][0]!
  243. if (kind === 'reordered') rows.reverse()
  244. if (kind === 'changed') rows[0] = { ...rows[0], content: 'foreign revision' }
  245. if (kind === 'extra') rows.push({ role: 'user', content: 'foreign message', createdAt: new Date(3000).toISOString() })
  246. const before = JSON.stringify(rows)
  247. message('user', 'new suffix', { createdAt: 4000 })
  248. await drain()
  249. expect(sync.status().failed).toBe(1)
  250. sync.close(); sync = new OpenVikingSessionSync(store, service); await drain()
  251. sync.retry(); await drain()
  252. expect(JSON.stringify(rows)).toBe(before)
  253. expect(sync.status().failed).toBe(1)
  254. expect(clients.every(client => client.deleteSession.mock.calls.length === 0)).toBe(true)
  255. })
  256. it('does not deduplicate distinct local messages with identical text and timestamps', async () => {
  257. message('user', 'repeat', { createdAt: 1000 }); message('user', 'repeat', { createdAt: 1000 })
  258. failAfterWrite = true; await drain()
  259. expect(texts()).toEqual(['repeat', 'repeat'])
  260. expect(sync.status().complete).toBe(1)
  261. })
  262. it('uses local sequence rather than client clocks and keeps it stable after reconnect', async () => {
  263. const first = message('user', 'client A first', { createdAt: 2000 })
  264. const second = message('assistant', 'client B second', { createdAt: 1000 })
  265. await drain()
  266. store.saveMessage('owner', { ...first, seq: 11 }); store.saveMessage('owner', { ...second, seq: 12 })
  267. sync.close(); sync = new OpenVikingSessionSync(store, service); await drain()
  268. expect(texts()).toEqual(['client A first', 'client B second'])
  269. expect(sync.status().complete).toBe(1)
  270. })
  271. it('rejects remote history loss instead of appending an already acknowledged transcript again', async () => {
  272. message('user', 'acknowledged'); await drain()
  273. const rows = [...remote.values()][0]!
  274. rows.length = 0
  275. message('user', 'later'); await drain()
  276. expect(rows).toEqual([])
  277. expect(sync.status().failed).toBe(1)
  278. })