openViking.test.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  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 removed = new Set<string>()
  17. const admin = {
  18. adminRegisterUser: vi.fn(async () => ({ user_key: `user-key-${admin.adminRegisterUser.mock.calls.length}` })),
  19. adminRegenerateKey: vi.fn(async () => ({ user_key: 'regenerated-key' })),
  20. adminRemoveUser: vi.fn(async () => ({ ok: true })),
  21. }
  22. const clients = new Map<string, { apiKey: string; client: unknown }>()
  23. function mockClient(apiKey: string, user?: string): OpenVikingClient {
  24. return {
  25. read: vi.fn(async (uri: string) => {
  26. if (!files.has(uri)) throw new OpenVikingError(`not found: ${uri}`, { code: 'NOT_FOUND', statusCode: 404 })
  27. return files.get(uri)!
  28. }),
  29. write: vi.fn(async (uri: string, content: string) => { files.set(uri, content); return { ok: true } }),
  30. remove: vi.fn(async (uri: string) => { files.delete(uri); removed.add(uri) }),
  31. setTags: vi.fn(async () => ({ ok: true })),
  32. list: vi.fn(async (uri: string) => [...files.keys()].filter(name => name.startsWith(uri)).map(name => name.slice(uri.length + 1))),
  33. find: vi.fn(async (query: string) => ({ memories: [...files.keys()].filter(uri => uri.includes('/memories/') && files.get(uri)?.includes(query)).map(uri => ({ uri })) })),
  34. } as unknown as OpenVikingClient
  35. }
  36. beforeEach(async () => {
  37. home = mkdtempSync(join(tmpdir(), 'yaoyao-openviking-'))
  38. store = new WorkspaceStore(home)
  39. manager = new OpenVikingConfigurationManager(home, undefined, async () => undefined)
  40. await manager.update({ enabled: true, url: 'http://127.0.0.1:1933', accountId: 'account', adminKey: 'admin-key' })
  41. service = new OpenVikingService(store, manager, config => {
  42. if (!config.user) return admin as unknown as OpenVikingClient
  43. let entry = clients.get(config.user)
  44. if (!entry || entry.apiKey !== config.apiKey) {
  45. entry = { apiKey: config.apiKey, client: mockClient(config.apiKey, config.user) }
  46. clients.set(config.user, entry)
  47. }
  48. return entry.client as OpenVikingClient
  49. })
  50. knowledge = new WorkspaceKnowledge(home, store, service)
  51. a = store.createAgent(owner, { name: '甲', profile: 'same' })
  52. b = store.createAgent(owner, { name: '乙', profile: 'same' })
  53. })
  54. afterEach(() => { store.close(); rmSync(home, { recursive: true, force: true }); files.clear(); removed.clear(); clients.clear(); vi.clearAllMocks() })
  55. describe('OpenViking configuration', () => {
  56. it('stores the administrator key encrypted with private file permissions', async () => {
  57. expect(openVikingConfigurationPath(home)).toBeTruthy()
  58. expect(openVikingKeyPath(home)).toBeTruthy()
  59. expect(statSync(openVikingConfigurationPath(home)).mode & 0o777).toBe(0o600)
  60. expect(statSync(openVikingKeyPath(home)).mode & 0o777).toBe(0o600)
  61. expect(readFileSync(openVikingConfigurationPath(home), 'utf8')).not.toContain('admin-key')
  62. expect(manager.snapshot()).toMatchObject({ enabled: true, keyConfigured: true, source: 'file', status: 'ready' })
  63. })
  64. it('rolls back a failed update', async () => {
  65. const before = readFileSync(openVikingConfigurationPath(home))
  66. const probe = vi.fn(async () => { throw new Error('offline') })
  67. const failing = new OpenVikingConfigurationManager(home, undefined, probe)
  68. await expect(failing.update({ enabled: true, url: 'http://127.0.0.1:9999', accountId: 'other', adminKey: 'new-key' })).rejects.toThrow('OpenViking 连接验证失败')
  69. expect(readFileSync(openVikingConfigurationPath(home))).toEqual(before)
  70. expect(failing.snapshot().keyConfigured).toBe(true)
  71. })
  72. it('lets service environment variables override file settings', () => {
  73. const env = {
  74. HERMES_YAOYAO_OPENVIKING_URL: 'http://127.0.0.1:2933',
  75. HERMES_YAOYAO_OPENVIKING_ACCOUNT_ID: 'env-account',
  76. HERMES_YAOYAO_OPENVIKING_ADMIN_KEY: 'env-key',
  77. }
  78. expect(loadOpenVikingConfiguration(home, env)).toMatchObject({ enabled: true, url: env.HERMES_YAOYAO_OPENVIKING_URL, accountId: 'env-account', keyConfigured: true, source: 'environment', status: 'ready' })
  79. })
  80. })
  81. describe('OpenViking memory provider', () => {
  82. it('lazily binds one deterministic user per existing Bot', async () => {
  83. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, content: '甲的经验', tier: 'profile' })
  84. expect(admin.adminRegisterUser).toHaveBeenCalledWith('account', openVikingUserId(a.id), 'user', expect.anything())
  85. const binding = store.require(owner, 'openviking-binding', a.id)
  86. expect(binding.userId).toBe(openVikingUserId(a.id))
  87. expect(JSON.stringify(binding)).not.toContain('user-key-')
  88. await service.removeUser(owner, a)
  89. expect(admin.adminRemoveUser).toHaveBeenCalledWith('account', binding.userId)
  90. expect(store.require(owner, 'openviking-binding', a.id).status).toBe('removed')
  91. })
  92. it('recovers an existing remote user without a local key binding', async () => {
  93. admin.adminRegisterUser.mockResolvedValueOnce({})
  94. await expect(service.ensureUser(owner, a)).rejects.toThrowError(expect.objectContaining({ code: 'openviking_unavailable' }))
  95. admin.adminRegisterUser.mockRejectedValueOnce(new OpenVikingError('user exists', { code: 'USER_EXISTS', statusCode: 409 }))
  96. const binding = await service.ensureUser(owner, a)
  97. expect(binding.userId).toBe(openVikingUserId(a.id))
  98. expect(JSON.stringify(binding)).not.toContain('regenerated-key')
  99. expect(admin.adminRegenerateKey).toHaveBeenCalledWith('account', binding.userId, `yaoyao:${a.id}`)
  100. expect(service.userClient(owner, a.id)).toBeTruthy()
  101. expect(clients.get(binding.userId)?.apiKey).toBe('regenerated-key')
  102. })
  103. it('keeps user memory isolated per contributing Bot', async () => {
  104. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'user', agentId: a.id, topic: '接口', content: '甲认为使用版本一', tier: 'profile' })
  105. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'user', agentId: b.id, topic: '接口', content: '乙认为使用版本二', tier: 'profile' })
  106. const memories = await knowledge.memories(owner, { scope: 'user' })
  107. expect(memories).toHaveLength(2)
  108. expect(memories.every(memory => memory.conflict)).toBe(true)
  109. expect((await knowledge.memories(owner, { scope: 'user', search: '版本二' })).map(memory => memory.agentId)).toEqual([b.id])
  110. })
  111. it('enforces revisions, idempotency and forgotten facts', async () => {
  112. const requestId = randomUUID(), input = { requestId, scope: 'agent' as const, agentId: a.id, content: '旧事实', tier: 'profile' as const }
  113. const memory = await knowledge.writeMemory(owner, input)
  114. expect(await knowledge.writeMemory(owner, input)).toEqual(memory)
  115. await expect(knowledge.writeMemory(owner, { ...input, content: '不同事实' })).rejects.toThrowError(expect.objectContaining({ code: 'idempotency_conflict' }))
  116. await expect(knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), id: memory.id, expectedRevision: 0, content: '新事实' })).rejects.toThrowError(expect.objectContaining({ code: 'memory_revision_conflict' }))
  117. const edited = await knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), id: memory.id, expectedRevision: memory.revision, content: '新事实' })
  118. expect(edited.revision).toBe(2)
  119. await knowledge.forget(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, id: edited.id, expectedRevision: edited.revision })
  120. expect(await knowledge.memories(owner, { scope: 'agent', agentId: a.id })).toEqual([])
  121. expect((await knowledge.revisions(owner, { scope: 'agent', agentId: a.id }, edited.id)).at(-1)?.operation).toBe('write')
  122. await expect(knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), automatic: true, content: '新事实' })).rejects.toThrowError(expect.objectContaining({ code: 'memory_forgotten' }))
  123. expect([...removed].some(uri => uri.includes('/memories/yaoyao/agent/'))).toBe(true)
  124. expect([...files.keys()].some(uri => uri.includes('/tombstones/'))).toBe(true)
  125. })
  126. it('uses context without falling back to local files', async () => {
  127. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, content: '上下文事实', tier: 'profile' })
  128. const context = await knowledge.context(owner, a.id)
  129. expect(context.text).toContain('上下文事实')
  130. expect(context.text).toContain('来源 Bot')
  131. expect(existsSync(join(home, 'bot-workspace', owner, 'agents', a.id, 'memory', 'profile.md'))).toBe(false)
  132. })
  133. })