openVikingMemory.live.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. // @vitest-environment node
  2. import { expect, it, vi } from 'vitest'
  3. import { createServer, type Server } from 'node:http'
  4. import { spawn, type ChildProcess } from 'node:child_process'
  5. import { mkdtemp, writeFile, rm } from 'node:fs/promises'
  6. import { tmpdir } from 'node:os'
  7. import { join } from 'node:path'
  8. import { randomUUID } from 'node:crypto'
  9. import { OpenVikingClient } from '@openviking/sdk'
  10. import { WorkspaceStore } from '../../src/server/workspaceStore'
  11. import { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
  12. import { WorkspaceMemorySynthesis } from '../../src/server/workspaceMemorySynthesis'
  13. import { OpenVikingConfigurationManager } from '../../src/server/openVikingConfiguration'
  14. import { OpenVikingService } from '../../src/server/openVikingService'
  15. import { UploadStore } from '../../src/server/uploads'
  16. import type { WorkspaceNodes } from '../../src/server/workspaceGateway'
  17. import type { WorkspaceConversation } from '../../src/shared/workspace'
  18. // Starts its own real server and disposable account. Never accepts a production URL/key.
  19. const python = process.env.YAOYAO_OPENVIKING_PYTHON
  20. const listen = async (server: Server) => {
  21. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  22. return (server.address() as { port: number }).port
  23. }
  24. const close = async (server: Server) => {
  25. await new Promise<void>(resolve => { server.close(() => resolve()); server.closeAllConnections() })
  26. }
  27. async function stop(child: ChildProcess): Promise<void> {
  28. if (!child.pid || child.exitCode !== null || child.signalCode !== null) return
  29. await new Promise<void>(resolve => {
  30. const timer = setTimeout(() => child.kill('SIGKILL'), 5000)
  31. child.once('exit', () => { clearTimeout(timer); resolve() })
  32. child.kill('SIGTERM')
  33. })
  34. }
  35. it.skipIf(!python)('extracts into a new Bot namespace, reads, searches and injects memory with real OpenViking', async () => {
  36. const home = await mkdtemp(join(tmpdir(), 'yaoyao-openviking-live-'))
  37. const rootKey = randomUUID(), owner = 'fixture-owner'
  38. // Deterministic local model endpoints keep this a storage/protocol test with no external calls.
  39. const model = createServer(async (req, res) => {
  40. let raw = ''; for await (const chunk of req) raw += chunk
  41. const body = raw ? JSON.parse(raw) : {}
  42. res.setHeader('Content-Type', 'application/json')
  43. if (req.url?.endsWith('/embeddings')) {
  44. const input = Array.isArray(body.input) ? body.input : [body.input]
  45. res.end(JSON.stringify({ object: 'list', model: 'fixture-embedding', data: input.map((_, index) => ({ object: 'embedding', index, embedding: [1, 0, 0, 0, 0, 0, 0, 0] })), usage: { prompt_tokens: 1, total_tokens: 1 } }))
  46. } else if (req.method === 'GET') res.end(JSON.stringify({ data: [{ id: 'fixture-model' }] }))
  47. else res.end(JSON.stringify({ id: randomUUID(), object: 'chat.completion', model: 'fixture-model', choices: [{ index: 0, message: { role: 'assistant', content: '已确认的 Bot 工作环境。' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }))
  48. })
  49. let child: ChildProcess | undefined, store: WorkspaceStore | undefined, uploads: UploadStore | undefined
  50. let runtime: WorkspaceRuntime | undefined, synthesis: WorkspaceMemorySynthesis | undefined, logs = '', startupError = ''
  51. try {
  52. const modelPort = await listen(model)
  53. const reservation = createServer(), port = await listen(reservation); await close(reservation)
  54. const baseUrl = `http://127.0.0.1:${port}`, apiBase = `http://127.0.0.1:${modelPort}/v1`
  55. const configPath = join(home, 'ov.conf')
  56. await writeFile(configPath, JSON.stringify({
  57. server: { host: '127.0.0.1', port, root_api_key: rootKey, auth_mode: 'api_key', with_bot: false },
  58. storage: { workspace: join(home, 'viking-data') },
  59. embedding: { dense: { provider: 'openai', model: 'fixture-embedding', api_key: 'fixture', api_base: apiBase, dimension: 8, input: 'text', encoding_format: 'float' } },
  60. vlm: { provider: 'openai', model: 'fixture-model', api_key: 'fixture', api_base: apiBase, max_retries: 0 },
  61. }), { mode: 0o600 })
  62. child = spawn(python!, ['-m', 'openviking_cli.server_bootstrap', '--config', configPath, '--host', '127.0.0.1', '--port', String(port)], {
  63. cwd: home, env: { PATH: process.env.PATH, HOME: process.env.HOME, PYTHONDONTWRITEBYTECODE: '1', NO_PROXY: '127.0.0.1,localhost' }, stdio: ['ignore', 'pipe', 'pipe'],
  64. })
  65. child.stdout!.on('data', chunk => { logs = (logs + String(chunk)).slice(-12000) })
  66. child.stderr!.on('data', chunk => { logs = (logs + String(chunk)).slice(-12000) })
  67. child.once('error', error => { startupError = error.message })
  68. await expect.poll(async () => {
  69. if (startupError) throw new Error(startupError)
  70. if (child!.exitCode !== null) throw new Error(logs)
  71. try { return (await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1000) })).status } catch { return 0 }
  72. }, { timeout: 45000 }).toBe(200)
  73. const admin = new OpenVikingClient({ baseUrl, apiKey: rootKey })
  74. await admin.adminCreateAccount('fixture-bots', 'admin')
  75. store = new WorkspaceStore(join(home, 'web'))
  76. uploads = new UploadStore(store.home)
  77. const manager = new OpenVikingConfigurationManager(store.home)
  78. await manager.update({ enabled: true, url: baseUrl, accountId: 'fixture-bots', adminKey: rootKey })
  79. const service = new OpenVikingService(store, manager)
  80. runtime = new WorkspaceRuntime(store, { requireSource() {} } as unknown as WorkspaceNodes, uploads, undefined, undefined, service)
  81. vi.spyOn(runtime, 'wake').mockImplementation(() => {})
  82. synthesis = new WorkspaceMemorySynthesis(runtime)
  83. const agent = store.createAgent(owner, { name: '环境助手', profile: 'default' })
  84. const other = store.createAgent(owner, { name: '另一位助手', profile: 'default' })
  85. const conversation = store.list<WorkspaceConversation>(owner, 'conversation').find(c => c.memberIds.includes(agent.id))!
  86. const content = 'app 项目在开发机 studio 的 /workspace/app 开发,服务端部署在 server。'
  87. const run = runtime.send(owner, conversation.id, { requestId: randomUUID(), content })
  88. store.saveMessage(owner, { id: randomUUID(), conversationId: conversation.id, seq: 0, role: 'assistant', agentId: agent.id, content: '已确认本次项目的环境与分工。', status: 'complete', runId: run.id, tools: [], attachments: [], createdAt: Date.now() })
  89. run.status = 'complete'; store.saveRun(owner, run)
  90. synthesis.enqueue(owner, run)
  91. synthesis.infer = async () => JSON.stringify({ memories: [{ scope: 'agent', tier: 'log', content, sourceMessageId: run.messageId, quote: content }] })
  92. await synthesis.tick()
  93. const job = runtime.knowledge.jobs(owner)[0]!
  94. expect(job, logs).toMatchObject({ status: 'complete', result: { extractedCount: 1, writtenCount: 1 } })
  95. const own = await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })
  96. expect(own.map(memory => memory.content)).toEqual([content])
  97. expect((await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id, search: 'studio' })).map(memory => memory.id)).toEqual(own.map(memory => memory.id))
  98. expect((await runtime.knowledge.context(owner, agent.id)).text).toContain(content)
  99. expect((await runtime.knowledge.context(owner, other.id)).text).not.toContain(content)
  100. await runtime.knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: other.id, content: '另一位 Bot 的私有部署约定。', tier: 'log' })
  101. expect((await runtime.knowledge.context(owner, agent.id)).text).not.toContain('另一位 Bot 的私有部署约定')
  102. const remote = await service.userClient(owner, agent.id).find('studio', { targetUri: 'viking://~/memories/yaoyao/agent', tags: ['yaoyao_memory=true'] })
  103. expect(remote.memories, logs).not.toHaveLength(0)
  104. const duplicate = await runtime.knowledge.writeMemoryWithResult(owner, { requestId: randomUUID(), scope: 'agent', agentId: agent.id, content, tier: 'log' })
  105. expect(duplicate.outcome).toBe('duplicate')
  106. expect(await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })).toHaveLength(1)
  107. await expect(service.userClient(owner, other.id).read(`viking://user/${service.binding(owner, agent.id)!.userId}/memories/yaoyao/agent/${own[0]!.id}.json`)).rejects.toThrow()
  108. } finally {
  109. synthesis?.close(); runtime?.close(); uploads?.close(); store?.close()
  110. if (child) await stop(child)
  111. await close(model)
  112. await rm(home, { recursive: true, force: true })
  113. vi.restoreAllMocks()
  114. }
  115. }, 120000)