openVikingSessionSync.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  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. const notFound = () => new OpenVikingError('not found', { code: 'NOT_FOUND', statusCode: 404 })
  17. const config = { enabled: true, url: 'http://localhost:1933', accountId: 'test', adminKey: 'admin-secret' }
  18. beforeEach(async () => {
  19. home = mkdtempSync(join(tmpdir(), 'yaoyao-session-sync-')); store = new WorkspaceStore(home)
  20. const manager = new OpenVikingConfigurationManager(home, undefined, async () => {})
  21. await manager.update(config)
  22. service = new OpenVikingService(store, manager, identity => {
  23. const prefix = `${identity.baseUrl}:${identity.account}:${identity.user}:`
  24. const client = {
  25. adminRegisterUser: vi.fn(async () => ({ user_key: randomUUID() })),
  26. adminRemoveUser: vi.fn(async (_account: string, user: string) => { for (const key of remote.keys()) if (key.includes(`:${user}:`)) remote.delete(key) }),
  27. createSession: vi.fn(async ({ sessionId }: any) => { if (!remote.has(prefix + sessionId)) remote.set(prefix + sessionId, []); return { session_id: sessionId } }),
  28. 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 } }),
  29. 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')),
  30. batchAddMessages: vi.fn(async (id: string, messages: any[]) => { remote.get(prefix + id)!.push(...messages); if (failAfterWrite) { failAfterWrite = false; throw new Error('response lost') } return {} }),
  31. deleteSession: vi.fn(async (id: string) => { remote.delete(prefix + id) }),
  32. commitSession: vi.fn(),
  33. }
  34. clients.push(client); return client as any
  35. })
  36. vi.spyOn(service, 'disableSessionAutoCommit').mockImplementation(async () => { autoCommit = false })
  37. agent = store.createAgent('owner', { name: 'Bot', profile: 'default' })
  38. conversation = store.list<WorkspaceConversation>('owner', 'conversation')[0]!
  39. sync = new OpenVikingSessionSync(store, service)
  40. })
  41. afterEach(() => { sync.close(); store.close(); rmSync(home, { recursive: true, force: true }); remote.clear(); clients.length = 0; offline = false; failAfterWrite = false; autoCommit = false; vi.restoreAllMocks() })
  42. function message(role: 'user' | 'assistant' | 'system', content: string, fields: Partial<WorkspaceMessage> = {}) {
  43. 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 }
  44. store.saveMessage('owner', value); return value
  45. }
  46. function unthrottle() { store.db.prepare('UPDATE openviking_sync_jobs SET next_at=0').run() }
  47. async function drain(times = 6) { for (let i = 0; i < times; i++) { unthrottle(); await sync.tick() } }
  48. const texts = () => [...remote.values()].flat().map(row => row.content)
  49. it('backfills archived history in pages and only uploads final visible transcript text', async () => {
  50. for (let i = 0; i < 260; i++) message(i % 2 ? 'assistant' : 'user', `message ${i}`)
  51. message('assistant', 'hidden', { visible: false })
  52. store.put('owner', 'conversation', conversation.id, { ...store.require('owner', 'conversation', conversation.id), archived: true })
  53. await sync.tick()
  54. expect(remote.size).toBe(0)
  55. await drain()
  56. expect(texts()).toHaveLength(260)
  57. expect(texts()[259]).toBe('message 259')
  58. expect(JSON.stringify([...remote.values()])).not.toMatch(/private thought|private tool|hidden/)
  59. expect(sync.status()).toMatchObject({ backfilling: false, complete: 1, pending: 0 })
  60. expect(clients.every(client => client.commitSession.mock.calls.length === 0)).toBe(true)
  61. })
  62. it('does not duplicate messages when an append succeeds remotely but the response is lost, including after restart', async () => {
  63. message('user', 'hello'); message('assistant', 'answer'); failAfterWrite = true
  64. await sync.tick()
  65. expect(texts()).toEqual(['hello', 'answer'])
  66. sync.close(); sync = new OpenVikingSessionSync(store, service)
  67. await drain()
  68. expect(texts()).toEqual(['hello', 'answer'])
  69. expect(sync.status().complete).toBe(1)
  70. })
  71. it('waits for streaming replies, preserves interrupted text, and catches up after disabling', async () => {
  72. message('user', 'first')
  73. const reply = message('assistant', 'partial', { status: 'streaming' })
  74. message('user', 'later')
  75. await sync.tick(); expect(texts()).toEqual(['first'])
  76. service.configure(undefined)
  77. store.saveMessage('owner', { ...reply, content: 'partial final', status: 'interrupted' })
  78. await sync.tick(); expect(texts()).toEqual(['first'])
  79. service.configure(config); await drain()
  80. expect(texts()).toEqual(['first', 'partial final', 'later'])
  81. })
  82. it('stops after three failures and retries on request without blocking local chat writes', async () => {
  83. message('user', 'hello'); offline = true
  84. await drain()
  85. expect(sync.status()).toMatchObject({ failed: 1, lastError: 'offline' })
  86. message('user', 'still chatting'); await sync.tick()
  87. expect(sync.status().failed).toBe(1)
  88. offline = false; sync.retry(); await drain()
  89. expect(texts()).toEqual(['hello', 'still chatting'])
  90. expect(sync.status()).toMatchObject({ failed: 0, complete: 1 })
  91. })
  92. it('keeps Bot namespaces separate and does not create sessions for temporary helpers', async () => {
  93. message('assistant', 'first bot')
  94. const second = store.createAgent('owner', { name: 'Second', profile: 'default' })
  95. const secondConversation = store.list<WorkspaceConversation>('owner', 'conversation').find(row => row.memberIds[0] === second.id)!
  96. message('assistant', 'second bot', { conversationId: secondConversation.id, agentId: second.id })
  97. const helper = store.createAgent('owner', { name: 'Temporary', profile: 'default' })
  98. store.put('owner', 'agent', helper.id, { ...helper, temporaryGoalId: randomUUID() })
  99. message('assistant', 'helper secret', { agentId: helper.id })
  100. await drain()
  101. expect(remote.size).toBe(2)
  102. expect([...remote.values()].every(rows => rows.length === 1)).toBe(true)
  103. expect(texts()).not.toContain('helper secret')
  104. service.configure({ ...config, accountId: 'new-account' }); await drain()
  105. expect(remote.size).toBe(4)
  106. })
  107. it('cleans up deleted conversations and cannot resurrect them on restart', async () => {
  108. message('user', 'hello'); await drain(); expect(remote.size).toBe(1)
  109. store.remove('owner', 'conversation', conversation.id)
  110. store.event('owner', 'conversation.deleted', { id: conversation.id }, conversation.id)
  111. await drain(); expect(remote.size).toBe(0)
  112. sync.close(); sync = new OpenVikingSessionSync(store, service); await drain()
  113. expect(remote.size).toBe(0)
  114. })
  115. it('disables inherited native auto-commit and preserves the paused automatic-memory preference', async () => {
  116. autoCommit = true; store.put('owner', 'agent', agent.id, { ...agent, memoryEnabled: false })
  117. message('user', 'hello'); await drain()
  118. expect(service.disableSessionAutoCommit).toHaveBeenCalledTimes(1)
  119. expect(texts()).toEqual(['hello'])
  120. expect(store.require('owner', 'agent', agent.id).memoryEnabled).toBe(false)
  121. })
  122. it('keeps group topics and participating Bots isolated, including their triggering user message', async () => {
  123. const second = store.createAgent('owner', { name: 'Second', profile: 'default' })
  124. const groupId = randomUUID()
  125. store.put('owner', 'conversation', groupId, { ...conversation, id: groupId, kind: 'group', memberIds: [agent.id, second.id], lastSeq: 0 })
  126. const taskIds = [randomUUID(), randomUUID()]
  127. for (const [index, taskId] of taskIds.entries()) {
  128. store.put('owner', 'conversation-task', taskId, { id: taskId, conversationId: groupId, memberIds: [agent.id, second.id], lastSeq: 0 })
  129. const source = message('user', `topic ${index}`, { conversationId: groupId, conversationTaskId: taskId })
  130. const runId = randomUUID(); store.put('owner', 'run', runId, { id: runId, messageId: source.id })
  131. message('assistant', `reply ${index}`, { conversationId: groupId, conversationTaskId: taskId, agentId: index ? second.id : agent.id, runId })
  132. }
  133. await drain()
  134. expect([...remote.values()].map(rows => rows.map(row => row.content))).toEqual([['topic 0', 'reply 0'], ['topic 1', 'reply 1']])
  135. store.remove('owner', 'conversation-task', taskIds[0]!)
  136. await drain(); expect([...remote.values()].map(rows => rows.map(row => row.content))).toEqual([['topic 1', 'reply 1']])
  137. })
  138. it('reconciles retained messages on restart even if the event log was pruned', async () => {
  139. message('user', 'before'); await drain(); sync.close()
  140. message('assistant', 'offline history')
  141. store.db.prepare('DELETE FROM workspace_events').run()
  142. sync = new OpenVikingSessionSync(store, service); await drain()
  143. expect(texts()).toEqual(['before', 'offline history'])
  144. })
  145. it('keeps attachment metadata without binary data and deletes removed Bots', async () => {
  146. 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] })
  147. await drain()
  148. expect(texts()[0]).toContain('proof.bin'); expect(texts()[0]).toContain('15')
  149. expect(texts()[0]).not.toMatch(/secret-binary|\/private/)
  150. store.remove('owner', 'agent', agent.id); await drain()
  151. expect(remote.size).toBe(0)
  152. })
  153. it('omits inline reasoning and finishes deletion queued while OpenViking was disabled', async () => {
  154. message('assistant', '<think>private thought</think>visible answer'); await drain()
  155. expect(texts()).toEqual(['visible answer'])
  156. service.configure(undefined); await sync.tick(); await service.removeUser('owner', agent)
  157. store.remove('owner', 'agent', agent.id)
  158. service.configure(config); await drain()
  159. expect(remote.size).toBe(0)
  160. expect(service.binding('owner', agent.id)).toMatchObject({ status: 'removed', pendingRemoval: false })
  161. })
  162. it('mirrors Bot communication bodies with sender and receiver roles, without internal display wrappers', async () => {
  163. const receiver = store.createAgent('owner', { name: 'Receiver', profile: 'default' })
  164. const destination = store.list<WorkspaceConversation>('owner', 'conversation').find(row => row.memberIds[0] === receiver.id)!
  165. const peerId = randomUUID()
  166. store.put('owner', 'peer-message', peerId, { id: peerId, fromAgentId: agent.id, toAgentId: receiver.id, originConversationId: conversation.id, conversationId: destination.id, content: 'collaboration body', fileIds: [] })
  167. message('system', 'internal wrapper', { peerMessageId: peerId })
  168. message('system', 'internal wrapper', { conversationId: destination.id, peerMessageId: peerId, runId: randomUUID() })
  169. await drain()
  170. expect([...remote.values()].flat().map(row => [row.role, row.content]).sort()).toEqual([['assistant', 'collaboration body'], ['user', 'collaboration body']])
  171. })