workspaceMemorySynthesis.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. // @vitest-environment node
  2. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { join } from 'node:path'
  5. import { tmpdir } from 'node:os'
  6. import { randomUUID } from 'node:crypto'
  7. import { WorkspaceStore } from '../../src/server/workspaceStore'
  8. import { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
  9. import { MEMORY_EXTRACTION_MAX_CHARS, WorkspaceMemorySynthesis } from '../../src/server/workspaceMemorySynthesis'
  10. import { UploadStore } from '../../src/server/uploads'
  11. import type { WorkspaceNodes } from '../../src/server/workspaceGateway'
  12. import type { WorkspaceAgent, WorkspaceConversation, WorkspaceMessage, WorkspaceRun } from '../../src/shared/workspace'
  13. import { HttpError } from '../../src/server/errors'
  14. const owner = 'synthesis-owner'
  15. let home: string, store: WorkspaceStore, runtime: WorkspaceRuntime, synthesis: WorkspaceMemorySynthesis, uploads: UploadStore, agent: WorkspaceAgent
  16. beforeEach(() => {
  17. home = mkdtempSync(join(tmpdir(), 'yaoyao-synthesis-')); store = new WorkspaceStore(home); uploads = new UploadStore(home)
  18. runtime = new WorkspaceRuntime(store, { requireSource: () => {} } as unknown as WorkspaceNodes, uploads)
  19. vi.spyOn(runtime, 'wake').mockImplementation(() => {})
  20. synthesis = new WorkspaceMemorySynthesis(runtime)
  21. agent = store.createAgent(owner, { name: '记忆成员', profile: 'default' })
  22. })
  23. afterEach(() => { synthesis.close(); runtime.close(); uploads.close(); store.close(); rmSync(home, { recursive: true, force: true }) })
  24. function turn(content = '以后我喜欢简洁的中文回复') {
  25. const c = store.list<WorkspaceConversation>(owner, 'conversation')[0]!
  26. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content })
  27. const reply: WorkspaceMessage = { id: randomUUID(), conversationId: c.id, seq: 0, role: 'assistant', agentId: agent.id, content: '将按此偏好回答', reasoning: 'private-execution-text', status: 'complete', runId: run.id, tools: [{ name: 'private-tool' }], attachments: [], createdAt: Date.now() }
  28. store.saveMessage(owner, reply); run.status = 'complete'; store.saveRun(owner, run)
  29. synthesis.enqueue(owner, run)
  30. return { run, source: store.require<WorkspaceMessage>(owner, 'message', run.messageId), reply }
  31. }
  32. it('runs extraction on server Hermes even for a legacy computer Bot',async()=>{
  33. const request=vi.fn(async()=>({status:200,body:Buffer.from(JSON.stringify({text:'{"memories":[]}'}))}))
  34. const target={session:{request}}
  35. runtime.nodes.target=vi.fn(()=>target) as any
  36. runtime.nodes.targetForAgent=vi.fn(()=>{throw new Error('VM must not run inference')})
  37. vi.spyOn(runtime,'botCapabilities').mockResolvedValue({memory:true,extraction:true} as any)
  38. await synthesis.infer(owner,{...agent,execution:'computer'},'extract facts')
  39. expect(runtime.nodes.target).toHaveBeenCalledWith(owner,agent.nodeId)
  40. expect(runtime.nodes.targetForAgent).not.toHaveBeenCalled()
  41. })
  42. it.each([{ kind: 'text', content: '正文'.repeat(6000) }, { kind: 'escaped Unicode', content: '\u0000"\\😀'.repeat(2000) }])('keeps long $kind evidence valid JSON within the complete request budget', async ({ content }) => {
  43. const { source, reply, run } = turn(content)
  44. const project = runtime.knowledge.saveProject(owner, { requestId: randomUUID(), name: '项目', description: '', memberIds: [agent.id], groupIds: [] })
  45. run.projectId = project.id; store.saveRun(owner, run)
  46. const job = runtime.knowledge.jobs(owner)[0]!
  47. runtime.knowledge.saveJob(owner, { ...job, projectId: project.id })
  48. for (const scope of ['agent', 'user', 'project'] as const) {
  49. for (let index = 0; index < 6; index++) await runtime.knowledge.writeMemory(owner, {
  50. requestId: randomUUID(), scope, agentId: agent.id, projectId: scope === 'project' ? project.id : undefined,
  51. content: `${scope}-${index}:`.padEnd(2000, '甲'), tier: 'profile',
  52. })
  53. }
  54. for (let index = 0; index < 6; index++) store.saveMessage(owner, { ...reply, id: randomUUID(), seq: 0, content })
  55. const infer = vi.fn(async (_owner, _agent, prompt: string) => {
  56. expect(prompt.length).toBeLessThanOrEqual(MEMORY_EXTRACTION_MAX_CHARS)
  57. const serialized = prompt.slice(prompt.indexOf('本轮证据:') + '本轮证据:'.length)
  58. const evidence = JSON.parse(serialized)
  59. expect(evidence[0].id).toBe(source.id)
  60. expect(evidence[0].content.length).toBeGreaterThan(0)
  61. expect(evidence.every((message: { content: string }) => source.content.includes(message.content) || message.content === reply.content)).toBe(true)
  62. expect(prompt).not.toContain('private-execution-text')
  63. return '{"memories":[]}'
  64. })
  65. synthesis.infer = infer
  66. await synthesis.tick()
  67. expect(infer).toHaveBeenCalledOnce()
  68. expect(runtime.knowledge.jobs(owner)[0]?.status).toBe('complete')
  69. })
  70. it('merges one agent’s visible results per user request and retains pending jobs across restart', async () => {
  71. const { run, source, reply } = turn()
  72. store.saveMessage(owner, { ...reply, id: randomUUID(), seq: 0, content: '另一条公开结果' })
  73. synthesis.enqueue(owner, run)
  74. expect(runtime.knowledge.jobs(owner)).toHaveLength(1)
  75. synthesis.close(); synthesis = new WorkspaceMemorySynthesis(runtime)
  76. const infer = vi.fn(async (_owner, _agent, prompt: string) => {
  77. expect(prompt).not.toContain('private-execution-text'); expect(prompt).not.toContain('private-tool')
  78. return JSON.stringify({ memories: [{ scope: 'user', content: '用户偏好简洁中文', tier: 'profile', sourceMessageId: source.id, quote: source.content }] })
  79. })
  80. synthesis.infer = infer
  81. await synthesis.tick()
  82. expect(infer).toHaveBeenCalledTimes(1)
  83. expect((await runtime.knowledge.memories(owner, { scope: 'user' }))[0]?.content).toBe('用户偏好简洁中文')
  84. expect(runtime.knowledge.jobs(owner)[0]?.status).toBe('complete')
  85. await synthesis.tick(); expect(infer).toHaveBeenCalledTimes(1)
  86. })
  87. it('rejects fabricated evidence and does not promote an assistant assertion into shared user memory', async () => {
  88. const { source, reply } = turn('请完成这一次的接口分析')
  89. synthesis.infer = async () => JSON.stringify({ memories: [
  90. { scope: 'agent', content: '不存在的事实', tier: 'log', sourceMessageId: source.id, quote: '证据里没有这句话' },
  91. { scope: 'user', content: '用户是架构师', tier: 'profile', sourceMessageId: reply.id, quote: reply.content },
  92. { scope: 'project', content: '无项目不应保存', tier: 'log', sourceMessageId: source.id, quote: source.content },
  93. ] })
  94. await synthesis.tick()
  95. expect(await runtime.knowledge.memories(owner, { scope: 'user' })).toEqual([])
  96. expect(await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })).toEqual([])
  97. expect(runtime.knowledge.jobs(owner)[0]?.result).toMatchObject({
  98. extractedCount: 3, writtenCount: 0, duplicateCount: 0, replayedCount: 0,
  99. skippedReasons: { quote_mismatch: 1, user_not_explicit: 1, project_unbound: 1 },
  100. })
  101. })
  102. it('reports an empty extraction separately from saved memories', async () => {
  103. turn('检查今天是否有新邮件')
  104. synthesis.infer = async () => '{"memories":[]}'
  105. const write = vi.spyOn(runtime.knowledge, 'writeMemoryWithResult')
  106. await synthesis.tick()
  107. expect(write).not.toHaveBeenCalled()
  108. expect(runtime.knowledge.jobs(owner)[0]).toMatchObject({ status: 'complete', result: {
  109. extractedCount: 0, writtenCount: 0, duplicateCount: 0, replayedCount: 0, skippedReasons: {},
  110. } })
  111. })
  112. it('retains confirmed Bot working context without a project and reports duplicate facts', async () => {
  113. const { source } = turn('开发机是 studio,项目文件位于 /workspace/app;服务端部署在 server。')
  114. const fact = { scope: 'agent', content: 'app 项目在 studio 的 /workspace/app 开发,服务端部署在 server。', tier: 'log', sourceMessageId: source.id, quote: source.content }
  115. synthesis.infer = async () => JSON.stringify({ memories: [fact, fact] })
  116. await synthesis.tick()
  117. const memories = await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })
  118. expect(memories).toHaveLength(1)
  119. expect(memories[0]).toMatchObject({ content: fact.content, scope: 'agent', tier: 'log', origin: 'synthesis' })
  120. expect((await runtime.knowledge.context(owner, agent.id)).text).toContain(fact.content)
  121. expect(runtime.knowledge.jobs(owner)[0]?.result).toMatchObject({ extractedCount: 2, writtenCount: 1, duplicateCount: 1 })
  122. })
  123. it('preserves partial results on a write failure and identifies replayed writes on retry', async () => {
  124. const { source } = turn('app 在开发机编译;部署时需要使用服务器。')
  125. synthesis.infer = async () => JSON.stringify({ memories: ['app 在开发机编译', 'app 部署时使用服务器'].map(content => ({
  126. scope: 'agent', tier: 'log', content, sourceMessageId: source.id, quote: source.content,
  127. })) })
  128. const write = runtime.knowledge.writeMemoryWithResult.bind(runtime.knowledge)
  129. let calls = 0
  130. vi.spyOn(runtime.knowledge, 'writeMemoryWithResult').mockImplementation((...args) => {
  131. if (++calls === 2) throw new HttpError(502, 'OpenViking 暂时不可用', 'openviking_unavailable')
  132. return write(...args)
  133. })
  134. await synthesis.tick()
  135. const pending = runtime.knowledge.jobs(owner)[0]!
  136. expect(pending).toMatchObject({ status: 'pending', result: { extractedCount: 2, writtenCount: 1, replayedCount: 0 }, error: 'OpenViking 暂时不可用' })
  137. runtime.knowledge.saveJob(owner, { ...pending, nextAt: 0 })
  138. await synthesis.tick()
  139. expect(runtime.knowledge.jobs(owner)[0]).toMatchObject({ status: 'complete', result: { extractedCount: 2, writtenCount: 1, replayedCount: 1, duplicateCount: 0 } })
  140. expect(await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })).toHaveLength(2)
  141. })
  142. it('honors pause during inference and retries failures without breaking the conversation', async () => {
  143. const { run } = turn()
  144. synthesis.infer = async () => { throw new Error('temporary inference outage') }
  145. await synthesis.tick()
  146. const failed = runtime.knowledge.jobs(owner)[0]!
  147. expect(failed.status).toBe('pending'); expect(failed.attempts).toBe(1)
  148. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('complete')
  149. runtime.knowledge.saveJob(owner, { ...failed, nextAt: 0 })
  150. synthesis.infer = async () => { store.updateAgent(owner, agent.id, { memoryEnabled: false }); return '{"memories":[]}' }
  151. await synthesis.tick()
  152. expect(runtime.knowledge.jobs(owner)[0]?.status).toBe('skipped')
  153. })
  154. it('does not recreate a forgotten fact by paraphrasing evidence from the same old turn', async () => {
  155. const { source } = turn()
  156. const fact = await runtime.knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: agent.id, content: '偏好简洁中文', tier: 'profile', sources: [{ messageId: source.id, conversationId: source.conversationId, quote: source.content }] })
  157. await runtime.knowledge.forget(owner, { requestId: randomUUID(), scope: 'agent', agentId: agent.id, id: fact.id, expectedRevision: fact.revision })
  158. synthesis.infer = async () => JSON.stringify({ memories: [{ scope: 'agent', content: '用中文简单回答用户', tier: 'profile', sourceMessageId: source.id, quote: source.content }] })
  159. await synthesis.tick()
  160. expect(await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })).toEqual([])
  161. expect(runtime.knowledge.jobs(owner)[0]?.result?.skippedReasons).toEqual({ forgotten: 1 })
  162. })
  163. it('waits for asynchronous collaboration and extracts the returned result only in its originating context', async () => {
  164. const conversation = store.list<WorkspaceConversation>(owner, 'conversation')[0]!
  165. const root = runtime.send(owner, conversation.id, { requestId: randomUUID(), content: '请联系同伴核对项目规范并记住结果' })
  166. root.status = 'complete'; store.saveRun(owner, root)
  167. const peerId = randomUUID(), replyRunId = randomUUID()
  168. store.put(owner, 'collaboration-chain', root.id, { id: root.id, rootRunId: root.id, stopped: false })
  169. const peer = { id: peerId, chainId: root.id, status: 'running', conversationId: conversation.id, runId: replyRunId }
  170. store.put(owner, 'peer-message', peerId, peer)
  171. synthesis.enqueue(owner, root)
  172. expect(runtime.knowledge.jobs(owner)).toEqual([])
  173. const reply: WorkspaceRun = { ...root, id: replyRunId, triggerKind: 'peer', peerMessageId: peerId, collaborationChainId: root.id }
  174. store.saveRun(owner, reply)
  175. store.put(owner, 'peer-message', peerId, { ...peer, status: 'complete' })
  176. const message: WorkspaceMessage = { id: randomUUID(), conversationId: conversation.id, seq: 0, role: 'assistant', agentId: agent.id, content: '核对结果:接口需要保持向后兼容', reasoning: '', status: 'complete', runId: replyRunId, tools: [], attachments: [], createdAt: Date.now() }
  177. store.saveMessage(owner, message)
  178. synthesis.enqueue(owner, reply)
  179. expect(runtime.knowledge.jobs(owner)).toHaveLength(1)
  180. synthesis.infer = async (_owner, _agent, prompt) => {
  181. expect(prompt).toContain(message.content)
  182. return JSON.stringify({ memories: [{ scope: 'agent', content: '接口需要保持向后兼容', tier: 'log', sourceMessageId: message.id, quote: message.content }] })
  183. }
  184. await synthesis.tick()
  185. expect((await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id }))[0]?.content).toBe('接口需要保持向后兼容')
  186. })