| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189 |
- // @vitest-environment node
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
- import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
- import { join } from 'node:path'
- import { tmpdir } from 'node:os'
- import { randomUUID } from 'node:crypto'
- import { OpenVikingClient, OpenVikingError } from '@openviking/sdk'
- import { loadOpenVikingConfiguration, OpenVikingConfigurationManager, openVikingConfigurationPath, openVikingKeyPath } from '../../src/server/openVikingConfiguration'
- import { OpenVikingService, openVikingUserId } from '../../src/server/openVikingService'
- import { WorkspaceStore } from '../../src/server/workspaceStore'
- import { WorkspaceKnowledge } from '../../src/server/workspaceKnowledge'
- import type { WorkspaceAgent } from '../../src/shared/workspace'
- const owner = 'openviking-owner'
- let home: string, store: WorkspaceStore, manager: OpenVikingConfigurationManager, service: OpenVikingService, knowledge: WorkspaceKnowledge, a: WorkspaceAgent, b: WorkspaceAgent
- const files = new Map<string, string>()
- const tagsByUri = new Map<string, string[]>()
- const removed = new Set<string>()
- const admin = {
- adminRegisterUser: vi.fn(async () => ({ user_key: `user-key-${admin.adminRegisterUser.mock.calls.length}` })),
- adminRegenerateKey: vi.fn(async () => ({ user_key: 'regenerated-key' })),
- adminRemoveUser: vi.fn(async () => ({ ok: true })),
- }
- const clients = new Map<string, { apiKey: string; client: unknown }>()
- function mockClient(apiKey: string, user?: string): OpenVikingClient {
- const validateTags = (tags: string[]) => {
- if (tags.some(tag => !/^[^=]+=[^=]+$/.test(tag))) throw new OpenVikingError('expected strict k=v format', { code: 'INVALID_ARGUMENT', statusCode: 400 })
- }
- return {
- read: vi.fn(async (uri: string) => {
- if (!files.has(uri)) throw new OpenVikingError(`not found: ${uri}`, { code: 'NOT_FOUND', statusCode: 404 })
- return files.get(uri)!
- }),
- write: vi.fn(async (uri: string, content: string) => { files.set(uri, content); return { ok: true } }),
- remove: vi.fn(async (uri: string) => { files.delete(uri); removed.add(uri) }),
- setTags: vi.fn(async (uri: string, tags: string[]) => { validateTags(tags); tagsByUri.set(uri, tags); return { ok: true } }),
- list: vi.fn(async (uri: string) => {
- const entries = [...files.keys()].filter(name => name.startsWith(uri)).map(name => name.slice(uri.length + 1))
- if (!entries.length) throw new OpenVikingError(`Directory not found: ${uri}`, { code: 'NOT_FOUND', statusCode: 404 })
- return entries
- }),
- find: vi.fn(async (query: string, options: { tags: string[] }) => {
- validateTags(options.tags)
- return { memories: [...files.keys()].filter(uri => uri.includes('/memories/') && files.get(uri)?.includes(query)
- && options.tags.every(tag => tagsByUri.get(uri)?.includes(tag))).map(uri => ({ uri })) }
- }),
- } as unknown as OpenVikingClient
- }
- beforeEach(async () => {
- home = mkdtempSync(join(tmpdir(), 'yaoyao-openviking-'))
- store = new WorkspaceStore(home)
- manager = new OpenVikingConfigurationManager(home, undefined, async () => undefined)
- await manager.update({ enabled: true, url: 'http://127.0.0.1:1933', accountId: 'account', adminKey: 'admin-key' })
- service = new OpenVikingService(store, manager, config => {
- if (!config.user) return admin as unknown as OpenVikingClient
- let entry = clients.get(config.user)
- if (!entry || entry.apiKey !== config.apiKey) {
- entry = { apiKey: config.apiKey, client: mockClient(config.apiKey, config.user) }
- clients.set(config.user, entry)
- }
- return entry.client as OpenVikingClient
- })
- knowledge = new WorkspaceKnowledge(home, store, service)
- a = store.createAgent(owner, { name: '甲', profile: 'same' })
- b = store.createAgent(owner, { name: '乙', profile: 'same' })
- })
- afterEach(() => { store.close(); rmSync(home, { recursive: true, force: true }); files.clear(); tagsByUri.clear(); removed.clear(); clients.clear(); vi.clearAllMocks() })
- describe('OpenViking configuration', () => {
- it('stores the administrator key encrypted with private file permissions', async () => {
- expect(openVikingConfigurationPath(home)).toBeTruthy()
- expect(openVikingKeyPath(home)).toBeTruthy()
- expect(statSync(openVikingConfigurationPath(home)).mode & 0o777).toBe(0o600)
- expect(statSync(openVikingKeyPath(home)).mode & 0o777).toBe(0o600)
- expect(readFileSync(openVikingConfigurationPath(home), 'utf8')).not.toContain('admin-key')
- expect(manager.snapshot()).toMatchObject({ enabled: true, keyConfigured: true, source: 'file', status: 'ready' })
- })
- it('rolls back a failed update', async () => {
- const before = readFileSync(openVikingConfigurationPath(home))
- const probe = vi.fn(async () => { throw new Error('offline') })
- const failing = new OpenVikingConfigurationManager(home, undefined, probe)
- await expect(failing.update({ enabled: true, url: 'http://127.0.0.1:9999', accountId: 'other', adminKey: 'new-key' })).rejects.toThrow('OpenViking 连接验证失败')
- expect(readFileSync(openVikingConfigurationPath(home))).toEqual(before)
- expect(failing.snapshot().keyConfigured).toBe(true)
- })
- it('lets service environment variables override file settings', () => {
- const env = {
- HERMES_YAOYAO_OPENVIKING_URL: 'http://127.0.0.1:2933',
- HERMES_YAOYAO_OPENVIKING_ACCOUNT_ID: 'env-account',
- HERMES_YAOYAO_OPENVIKING_ADMIN_KEY: 'env-key',
- }
- expect(loadOpenVikingConfiguration(home, env)).toMatchObject({ enabled: true, url: env.HERMES_YAOYAO_OPENVIKING_URL, accountId: 'env-account', keyConfigured: true, source: 'environment', status: 'ready' })
- })
- })
- describe('OpenViking memory provider', () => {
- it('reports writes, duplicates and idempotent replays without changing the public memory shape', async () => {
- const input = { requestId: randomUUID(), scope: 'agent' as const, agentId: a.id, content: '开发环境为 studio', tier: 'log' as const }
- const first = await knowledge.writeMemoryWithResult(owner, input)
- expect(first.outcome).toBe('written')
- expect((await knowledge.writeMemoryWithResult(owner, input)).outcome).toBe('replayed')
- const duplicate = await knowledge.writeMemoryWithResult(owner, { ...input, requestId: randomUUID() })
- expect(duplicate).toEqual({ memory: first.memory, outcome: 'duplicate' })
- expect(await knowledge.writeMemory(owner, input)).toEqual(first.memory)
- expect(tagsByUri.get(`viking://~/memories/yaoyao/agent/${first.memory.id}.json`)).toEqual([
- 'yaoyao_memory=true', 'yaoyao_scope=agent', `yaoyao_memory_id=${first.memory.id}`,
- ])
- expect((await knowledge.memories(owner, { scope: 'agent', agentId: a.id, search: 'studio' })).map(memory => memory.id)).toEqual([first.memory.id])
- })
- it('treats a missing search directory as empty while surfacing real upstream failures', async () => {
- await service.ensureUser(owner, a)
- const find = vi.mocked(service.userClient(owner, a.id).find)
- find.mockRejectedValueOnce(new OpenVikingError('Directory not found', { code: 'NOT_FOUND', statusCode: 404 }))
- expect(await knowledge.memories(owner, { scope: 'agent', agentId: a.id, search: '工作环境' })).toEqual([])
- find.mockRejectedValueOnce(new OpenVikingError('unavailable', { code: 'UNAVAILABLE', statusCode: 503 }))
- await expect(knowledge.memories(owner, { scope: 'agent', agentId: a.id, search: '工作环境' })).rejects.toMatchObject({ code: 'openviking_unavailable' })
- })
- it('preserves semantic matches without admitting another Bot’s private memories', async () => {
- const own = await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, content: '用户偏爱低糖饮食', tier: 'profile' })
- const privateFact = await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: b.id, content: '另一个 Bot 的私有记录', tier: 'profile' })
- vi.mocked(service.userClient(owner, a.id).find).mockResolvedValue({ memories: [own, privateFact].map(memory => ({ uri: `viking://~/memories/yaoyao/agent/${memory.id}.json` })) } as any)
- const matches = await knowledge.memories(owner, { scope: 'agent', agentId: a.id, search: '饮食习惯' }, { agentId: a.id })
- expect(matches.map(memory => memory.id)).toEqual([own.id])
- expect(matches[0]!.content).not.toContain('饮食习惯')
- })
- it('lazily binds one deterministic user per existing Bot', async () => {
- await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, content: '甲的经验', tier: 'profile' })
- expect(admin.adminRegisterUser).toHaveBeenCalledWith('account', openVikingUserId(a.id), 'user', expect.anything())
- const binding = store.require(owner, 'openviking-binding', a.id)
- expect(binding.userId).toBe(openVikingUserId(a.id))
- expect(JSON.stringify(binding)).not.toContain('user-key-')
- await service.removeUser(owner, a)
- expect(admin.adminRemoveUser).toHaveBeenCalledWith('account', binding.userId)
- expect(store.require(owner, 'openviking-binding', a.id).status).toBe('removed')
- })
- it('recovers an existing remote user without a local key binding', async () => {
- admin.adminRegisterUser.mockResolvedValueOnce({})
- await expect(service.ensureUser(owner, a)).rejects.toThrowError(expect.objectContaining({ code: 'openviking_unavailable' }))
- admin.adminRegisterUser.mockRejectedValueOnce(new OpenVikingError('user exists', { code: 'USER_EXISTS', statusCode: 409 }))
- const binding = await service.ensureUser(owner, a)
- expect(binding.userId).toBe(openVikingUserId(a.id))
- expect(JSON.stringify(binding)).not.toContain('regenerated-key')
- expect(admin.adminRegenerateKey).toHaveBeenCalledWith('account', binding.userId, `yaoyao:${a.id}`)
- expect(service.userClient(owner, a.id)).toBeTruthy()
- expect(clients.get(binding.userId)?.apiKey).toBe('regenerated-key')
- })
- it('keeps user memory isolated per contributing Bot', async () => {
- await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'user', agentId: a.id, topic: '接口', content: '甲认为使用版本一', tier: 'profile' })
- await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'user', agentId: b.id, topic: '接口', content: '乙认为使用版本二', tier: 'profile' })
- const memories = await knowledge.memories(owner, { scope: 'user' })
- expect(memories).toHaveLength(2)
- expect(memories.every(memory => memory.conflict)).toBe(true)
- expect((await knowledge.memories(owner, { scope: 'user', search: '版本二' })).map(memory => memory.agentId)).toEqual([b.id])
- })
- it('enforces revisions, idempotency and forgotten facts', async () => {
- const requestId = randomUUID(), input = { requestId, scope: 'agent' as const, agentId: a.id, content: '旧事实', tier: 'profile' as const }
- const memory = await knowledge.writeMemory(owner, input)
- expect(await knowledge.writeMemory(owner, input)).toEqual(memory)
- await expect(knowledge.writeMemory(owner, { ...input, content: '不同事实' })).rejects.toThrowError(expect.objectContaining({ code: 'idempotency_conflict' }))
- await expect(knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), id: memory.id, expectedRevision: 0, content: '新事实' })).rejects.toThrowError(expect.objectContaining({ code: 'memory_revision_conflict' }))
- const edited = await knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), id: memory.id, expectedRevision: memory.revision, content: '新事实' })
- expect(edited.revision).toBe(2)
- await knowledge.forget(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, id: edited.id, expectedRevision: edited.revision })
- expect(await knowledge.memories(owner, { scope: 'agent', agentId: a.id })).toEqual([])
- expect((await knowledge.revisions(owner, { scope: 'agent', agentId: a.id }, edited.id)).at(-1)?.operation).toBe('write')
- await expect(knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), automatic: true, content: '新事实' })).rejects.toThrowError(expect.objectContaining({ code: 'memory_forgotten' }))
- expect([...removed].some(uri => uri.includes('/memories/yaoyao/agent/'))).toBe(true)
- expect([...files.keys()].some(uri => uri.includes('/tombstones/'))).toBe(true)
- })
- it('uses context without falling back to local files', async () => {
- await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, content: '上下文事实', tier: 'profile' })
- const context = await knowledge.context(owner, a.id)
- expect(context.text).toContain('上下文事实')
- expect(context.text).toContain('来源 Bot')
- const { acknowledgeMemory, memoryDelta, memoryResetReason } = await import('../../src/server/workspaceMemoryContext')
- const state = acknowledgeMemory(context)
- const input = { requestId: randomUUID(), scope: 'agent' as const, agentId: a.id, content: '远端新增事实', tier: 'profile' as const }
- const added = await knowledge.writeMemory(owner, input)
- const updated = await knowledge.context(owner, a.id)
- expect(memoryResetReason(updated, state)).toBeUndefined()
- expect(memoryDelta(updated, state)).toContain('远端新增事实')
- expect(memoryDelta(updated, state)).not.toContain('上下文事实')
- await knowledge.forget(owner, { ...input, requestId: randomUUID(), id: added.id, expectedRevision: added.revision })
- expect(memoryResetReason(await knowledge.context(owner, a.id), acknowledgeMemory(updated, state))).toBe('memory_changed_or_removed')
- expect(existsSync(join(home, 'bot-workspace', owner, 'agents', a.id, 'memory', 'profile.md'))).toBe(false)
- })
- })
|