openViking.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. // @vitest-environment node
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
  4. import { join } from 'node:path'
  5. import { tmpdir } from 'node:os'
  6. import { randomUUID } from 'node:crypto'
  7. import { OpenVikingClient, OpenVikingError } from '@openviking/sdk'
  8. import { loadOpenVikingConfiguration, OpenVikingConfigurationManager, openVikingConfigurationPath, openVikingKeyPath } from '../../src/server/openVikingConfiguration'
  9. import { OpenVikingService, openVikingUserId } from '../../src/server/openVikingService'
  10. import { WorkspaceStore } from '../../src/server/workspaceStore'
  11. import { WorkspaceKnowledge } from '../../src/server/workspaceKnowledge'
  12. import type { WorkspaceAgent } from '../../src/shared/workspace'
  13. const owner = 'openviking-owner'
  14. let home: string, store: WorkspaceStore, manager: OpenVikingConfigurationManager, service: OpenVikingService, knowledge: WorkspaceKnowledge, a: WorkspaceAgent, b: WorkspaceAgent
  15. const files = new Map<string, string>()
  16. const tagsByUri = new Map<string, string[]>()
  17. const removed = new Set<string>()
  18. const admin = {
  19. adminRegisterUser: vi.fn(async () => ({ user_key: `user-key-${admin.adminRegisterUser.mock.calls.length}` })),
  20. adminRegenerateKey: vi.fn(async () => ({ user_key: 'regenerated-key' })),
  21. adminRemoveUser: vi.fn(async () => ({ ok: true })),
  22. }
  23. const clients = new Map<string, { apiKey: string; client: unknown }>()
  24. function mockClient(apiKey: string, user?: string): OpenVikingClient {
  25. const validateTags = (tags: string[]) => {
  26. if (tags.some(tag => !/^[^=]+=[^=]+$/.test(tag))) throw new OpenVikingError('expected strict k=v format', { code: 'INVALID_ARGUMENT', statusCode: 400 })
  27. }
  28. return {
  29. read: vi.fn(async (uri: string) => {
  30. if (!files.has(uri)) throw new OpenVikingError(`not found: ${uri}`, { code: 'NOT_FOUND', statusCode: 404 })
  31. return files.get(uri)!
  32. }),
  33. write: vi.fn(async (uri: string, content: string) => { files.set(uri, content); return { ok: true } }),
  34. remove: vi.fn(async (uri: string) => { files.delete(uri); removed.add(uri) }),
  35. setTags: vi.fn(async (uri: string, tags: string[]) => { validateTags(tags); tagsByUri.set(uri, tags); return { ok: true } }),
  36. list: vi.fn(async (uri: string) => {
  37. const entries = [...files.keys()].filter(name => name.startsWith(uri)).map(name => name.slice(uri.length + 1))
  38. if (!entries.length) throw new OpenVikingError(`Directory not found: ${uri}`, { code: 'NOT_FOUND', statusCode: 404 })
  39. return entries
  40. }),
  41. find: vi.fn(async (query: string, options: { tags: string[] }) => {
  42. validateTags(options.tags)
  43. return { memories: [...files.keys()].filter(uri => uri.includes('/memories/') && files.get(uri)?.includes(query)
  44. && options.tags.every(tag => tagsByUri.get(uri)?.includes(tag))).map(uri => ({ uri })) }
  45. }),
  46. } as unknown as OpenVikingClient
  47. }
  48. beforeEach(async () => {
  49. home = mkdtempSync(join(tmpdir(), 'yaoyao-openviking-'))
  50. store = new WorkspaceStore(home)
  51. manager = new OpenVikingConfigurationManager(home, undefined, async () => undefined)
  52. await manager.update({ enabled: true, url: 'http://127.0.0.1:1933', accountId: 'account', adminKey: 'admin-key' })
  53. service = new OpenVikingService(store, manager, config => {
  54. if (!config.user) return admin as unknown as OpenVikingClient
  55. let entry = clients.get(config.user)
  56. if (!entry || entry.apiKey !== config.apiKey) {
  57. entry = { apiKey: config.apiKey, client: mockClient(config.apiKey, config.user) }
  58. clients.set(config.user, entry)
  59. }
  60. return entry.client as OpenVikingClient
  61. })
  62. knowledge = new WorkspaceKnowledge(home, store, service)
  63. a = store.createAgent(owner, { name: '甲', profile: 'same' })
  64. b = store.createAgent(owner, { name: '乙', profile: 'same' })
  65. })
  66. afterEach(() => { store.close(); rmSync(home, { recursive: true, force: true }); files.clear(); tagsByUri.clear(); removed.clear(); clients.clear(); vi.clearAllMocks() })
  67. describe('OpenViking configuration', () => {
  68. it('stores the administrator key encrypted with private file permissions', async () => {
  69. expect(openVikingConfigurationPath(home)).toBeTruthy()
  70. expect(openVikingKeyPath(home)).toBeTruthy()
  71. expect(statSync(openVikingConfigurationPath(home)).mode & 0o777).toBe(0o600)
  72. expect(statSync(openVikingKeyPath(home)).mode & 0o777).toBe(0o600)
  73. expect(readFileSync(openVikingConfigurationPath(home), 'utf8')).not.toContain('admin-key')
  74. expect(manager.snapshot()).toMatchObject({ enabled: true, keyConfigured: true, source: 'file', status: 'ready' })
  75. })
  76. it('rolls back a failed update', async () => {
  77. const before = readFileSync(openVikingConfigurationPath(home))
  78. const probe = vi.fn(async () => { throw new Error('offline') })
  79. const failing = new OpenVikingConfigurationManager(home, undefined, probe)
  80. await expect(failing.update({ enabled: true, url: 'http://127.0.0.1:9999', accountId: 'other', adminKey: 'new-key' })).rejects.toThrow('OpenViking 连接验证失败')
  81. expect(readFileSync(openVikingConfigurationPath(home))).toEqual(before)
  82. expect(failing.snapshot().keyConfigured).toBe(true)
  83. })
  84. it('lets service environment variables override file settings', () => {
  85. const env = {
  86. HERMES_YAOYAO_OPENVIKING_URL: 'http://127.0.0.1:2933',
  87. HERMES_YAOYAO_OPENVIKING_ACCOUNT_ID: 'env-account',
  88. HERMES_YAOYAO_OPENVIKING_ADMIN_KEY: 'env-key',
  89. }
  90. expect(loadOpenVikingConfiguration(home, env)).toMatchObject({ enabled: true, url: env.HERMES_YAOYAO_OPENVIKING_URL, accountId: 'env-account', keyConfigured: true, source: 'environment', status: 'ready' })
  91. })
  92. })
  93. describe('OpenViking memory provider', () => {
  94. it('serializes removal after in-flight registration and never resurrects a removed user',async()=>{
  95. let release!:(value:{user_key:string})=>void
  96. admin.adminRegisterUser.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
  97. const registration=service.ensureUser(owner,a)
  98. const removal=service.removeUser(owner,a)
  99. await expect(service.ensureUser(owner,a)).rejects.toMatchObject({code:'openviking_user_removed'})
  100. expect(admin.adminRemoveUser).not.toHaveBeenCalled()
  101. release({user_key:'fixture-racing-key'})
  102. await registration;await removal
  103. expect(service.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:false})
  104. await expect(service.ensureUser(owner,a)).rejects.toMatchObject({code:'openviking_user_removed'})
  105. expect(admin.adminRegisterUser).toHaveBeenCalledTimes(1)
  106. expect(admin.adminRemoveUser).toHaveBeenCalledTimes(1)
  107. })
  108. it('persists failed removal for an idempotent retry after restarting the service',async()=>{
  109. await service.ensureUser(owner,a)
  110. admin.adminRemoveUser.mockRejectedValueOnce(new Error('fixture offline'))
  111. await expect(service.removeUser(owner,a)).rejects.toMatchObject({code:'openviking_unavailable'})
  112. expect(service.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:true})
  113. const restarted=new OpenVikingService(store,manager,()=>admin as unknown as OpenVikingClient)
  114. await restarted.removeUser(owner,a);await restarted.removeUser(owner,a)
  115. expect(restarted.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:false})
  116. expect(admin.adminRemoveUser).toHaveBeenCalledTimes(2)
  117. await expect(restarted.ensureUser(owner,a)).rejects.toMatchObject({code:'openviking_user_removed'})
  118. })
  119. it('does not lose removal when configuration is disabled during registration',async()=>{
  120. let release!:(value:{user_key:string})=>void
  121. admin.adminRegisterUser.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
  122. const registration=service.ensureUser(owner,a)
  123. service.configure(undefined)
  124. const removal=service.removeUser(owner,a)
  125. release({user_key:'fixture-register-before-disabled'})
  126. await registration;await removal
  127. expect(service.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:true})
  128. expect(admin.adminRemoveUser).not.toHaveBeenCalled()
  129. service.configure(manager.configuration())
  130. await service.removeUser(owner,a)
  131. expect(service.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:false})
  132. expect(admin.adminRemoveUser).toHaveBeenCalledTimes(1)
  133. })
  134. it('reports writes, duplicates and idempotent replays without changing the public memory shape', async () => {
  135. const input = { requestId: randomUUID(), scope: 'agent' as const, agentId: a.id, content: '开发环境为 studio', tier: 'log' as const }
  136. const first = await knowledge.writeMemoryWithResult(owner, input)
  137. expect(first.outcome).toBe('written')
  138. expect((await knowledge.writeMemoryWithResult(owner, input)).outcome).toBe('replayed')
  139. const duplicate = await knowledge.writeMemoryWithResult(owner, { ...input, requestId: randomUUID() })
  140. expect(duplicate).toEqual({ memory: first.memory, outcome: 'duplicate' })
  141. expect(await knowledge.writeMemory(owner, input)).toEqual(first.memory)
  142. expect(tagsByUri.get(`viking://~/memories/yaoyao/agent/${first.memory.id}.json`)).toEqual([
  143. 'yaoyao_memory=true', 'yaoyao_scope=agent', `yaoyao_memory_id=${first.memory.id}`,
  144. ])
  145. expect((await knowledge.memories(owner, { scope: 'agent', agentId: a.id, search: 'studio' })).map(memory => memory.id)).toEqual([first.memory.id])
  146. })
  147. it('treats a missing search directory as empty while surfacing real upstream failures', async () => {
  148. await service.ensureUser(owner, a)
  149. const find = vi.mocked(service.userClient(owner, a.id).find)
  150. find.mockRejectedValueOnce(new OpenVikingError('Directory not found', { code: 'NOT_FOUND', statusCode: 404 }))
  151. expect(await knowledge.memories(owner, { scope: 'agent', agentId: a.id, search: '工作环境' })).toEqual([])
  152. find.mockRejectedValueOnce(new OpenVikingError('unavailable', { code: 'UNAVAILABLE', statusCode: 503 }))
  153. await expect(knowledge.memories(owner, { scope: 'agent', agentId: a.id, search: '工作环境' })).rejects.toMatchObject({ code: 'openviking_unavailable' })
  154. })
  155. it('preserves semantic matches without admitting another Bot’s private memories', async () => {
  156. const own = await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, content: '用户偏爱低糖饮食', tier: 'profile' })
  157. const privateFact = await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: b.id, content: '另一个 Bot 的私有记录', tier: 'profile' })
  158. vi.mocked(service.userClient(owner, a.id).find).mockResolvedValue({ memories: [own, privateFact].map(memory => ({ uri: `viking://~/memories/yaoyao/agent/${memory.id}.json` })) } as any)
  159. const matches = await knowledge.memories(owner, { scope: 'agent', agentId: a.id, search: '饮食习惯' }, { agentId: a.id })
  160. expect(matches.map(memory => memory.id)).toEqual([own.id])
  161. expect(matches[0]!.content).not.toContain('饮食习惯')
  162. })
  163. it('lazily binds one deterministic user per existing Bot', async () => {
  164. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, content: '甲的经验', tier: 'profile' })
  165. expect(admin.adminRegisterUser).toHaveBeenCalledWith('account', openVikingUserId(a.id), 'user', expect.anything())
  166. const binding = store.require(owner, 'openviking-binding', a.id)
  167. expect(binding.userId).toBe(openVikingUserId(a.id))
  168. expect(JSON.stringify(binding)).not.toContain('user-key-')
  169. await service.removeUser(owner, a)
  170. expect(admin.adminRemoveUser).toHaveBeenCalledWith('account', binding.userId)
  171. expect(store.require(owner, 'openviking-binding', a.id).status).toBe('removed')
  172. })
  173. it('recovers an existing remote user without a local key binding', async () => {
  174. admin.adminRegisterUser.mockResolvedValueOnce({})
  175. await expect(service.ensureUser(owner, a)).rejects.toThrowError(expect.objectContaining({ code: 'openviking_unavailable' }))
  176. admin.adminRegisterUser.mockRejectedValueOnce(new OpenVikingError('user exists', { code: 'USER_EXISTS', statusCode: 409 }))
  177. const binding = await service.ensureUser(owner, a)
  178. expect(binding.userId).toBe(openVikingUserId(a.id))
  179. expect(JSON.stringify(binding)).not.toContain('regenerated-key')
  180. expect(admin.adminRegenerateKey).toHaveBeenCalledWith('account', binding.userId, `yaoyao:${a.id}`)
  181. expect(service.userClient(owner, a.id)).toBeTruthy()
  182. expect(clients.get(binding.userId)?.apiKey).toBe('regenerated-key')
  183. })
  184. it('keeps user memory isolated per contributing Bot', async () => {
  185. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'user', agentId: a.id, topic: '接口', content: '甲认为使用版本一', tier: 'profile' })
  186. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'user', agentId: b.id, topic: '接口', content: '乙认为使用版本二', tier: 'profile' })
  187. const memories = await knowledge.memories(owner, { scope: 'user' })
  188. expect(memories).toHaveLength(2)
  189. expect(memories.every(memory => memory.conflict)).toBe(true)
  190. expect((await knowledge.memories(owner, { scope: 'user', search: '版本二' })).map(memory => memory.agentId)).toEqual([b.id])
  191. })
  192. it('enforces revisions, idempotency and forgotten facts', async () => {
  193. const requestId = randomUUID(), input = { requestId, scope: 'agent' as const, agentId: a.id, content: '旧事实', tier: 'profile' as const }
  194. const memory = await knowledge.writeMemory(owner, input)
  195. expect(await knowledge.writeMemory(owner, input)).toEqual(memory)
  196. await expect(knowledge.writeMemory(owner, { ...input, content: '不同事实' })).rejects.toThrowError(expect.objectContaining({ code: 'idempotency_conflict' }))
  197. await expect(knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), id: memory.id, expectedRevision: 0, content: '新事实' })).rejects.toThrowError(expect.objectContaining({ code: 'memory_revision_conflict' }))
  198. const edited = await knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), id: memory.id, expectedRevision: memory.revision, content: '新事实' })
  199. expect(edited.revision).toBe(2)
  200. await knowledge.forget(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, id: edited.id, expectedRevision: edited.revision })
  201. expect(await knowledge.memories(owner, { scope: 'agent', agentId: a.id })).toEqual([])
  202. expect((await knowledge.revisions(owner, { scope: 'agent', agentId: a.id }, edited.id)).at(-1)?.operation).toBe('write')
  203. await expect(knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), automatic: true, content: '新事实' })).rejects.toThrowError(expect.objectContaining({ code: 'memory_forgotten' }))
  204. expect([...removed].some(uri => uri.includes('/memories/yaoyao/agent/'))).toBe(true)
  205. expect([...files.keys()].some(uri => uri.includes('/tombstones/'))).toBe(true)
  206. })
  207. it('uses context without falling back to local files', async () => {
  208. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, content: '上下文事实', tier: 'profile' })
  209. const context = await knowledge.context(owner, a.id)
  210. expect(context.text).toContain('上下文事实')
  211. expect(context.text).toContain('来源 Bot')
  212. const { acknowledgeMemory, memoryDelta, memoryResetReason } = await import('../../src/server/workspaceMemoryContext')
  213. const state = acknowledgeMemory(context)
  214. const input = { requestId: randomUUID(), scope: 'agent' as const, agentId: a.id, content: '远端新增事实', tier: 'profile' as const }
  215. const added = await knowledge.writeMemory(owner, input)
  216. const updated = await knowledge.context(owner, a.id)
  217. expect(memoryResetReason(updated, state)).toBeUndefined()
  218. expect(memoryDelta(updated, state)).toContain('远端新增事实')
  219. expect(memoryDelta(updated, state)).not.toContain('上下文事实')
  220. await knowledge.forget(owner, { ...input, requestId: randomUUID(), id: added.id, expectedRevision: added.revision })
  221. expect(memoryResetReason(await knowledge.context(owner, a.id), acknowledgeMemory(updated, state))).toBe('memory_changed_or_removed')
  222. expect(existsSync(join(home, 'bot-workspace', owner, 'agents', a.id, 'memory', 'profile.md'))).toBe(false)
  223. })
  224. })