workspaceMemorySynthesis.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  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 { 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. const owner = 'synthesis-owner'
  14. let home: string, store: WorkspaceStore, runtime: WorkspaceRuntime, synthesis: WorkspaceMemorySynthesis, uploads: UploadStore, agent: WorkspaceAgent
  15. beforeEach(() => {
  16. home = mkdtempSync(join(tmpdir(), 'yaoyao-synthesis-')); store = new WorkspaceStore(home); uploads = new UploadStore(home)
  17. runtime = new WorkspaceRuntime(store, { requireSource: () => {} } as unknown as WorkspaceNodes, uploads)
  18. vi.spyOn(runtime, 'wake').mockImplementation(() => {})
  19. synthesis = new WorkspaceMemorySynthesis(runtime)
  20. agent = store.createAgent(owner, { name: '记忆成员', profile: 'default' })
  21. })
  22. afterEach(() => { synthesis.close(); runtime.close(); uploads.close(); store.close(); rmSync(home, { recursive: true, force: true }) })
  23. function turn(content = '以后我喜欢简洁的中文回复') {
  24. const c = store.list<WorkspaceConversation>(owner, 'conversation')[0]!
  25. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content })
  26. 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() }
  27. store.saveMessage(owner, reply); run.status = 'complete'; store.saveRun(owner, run)
  28. synthesis.enqueue(owner, run)
  29. return { run, source: store.require<WorkspaceMessage>(owner, 'message', run.messageId), reply }
  30. }
  31. it('merges one agent’s visible results per user request and retains pending jobs across restart', async () => {
  32. const { run, source, reply } = turn()
  33. store.saveMessage(owner, { ...reply, id: randomUUID(), seq: 0, content: '另一条公开结果' })
  34. synthesis.enqueue(owner, run)
  35. expect(runtime.knowledge.jobs(owner)).toHaveLength(1)
  36. synthesis.close(); synthesis = new WorkspaceMemorySynthesis(runtime)
  37. const infer = vi.fn(async (_owner, _agent, prompt: string) => {
  38. expect(prompt).not.toContain('private-execution-text'); expect(prompt).not.toContain('private-tool')
  39. return JSON.stringify({ memories: [{ scope: 'user', content: '用户偏好简洁中文', tier: 'profile', sourceMessageId: source.id, quote: source.content }] })
  40. })
  41. synthesis.infer = infer
  42. await synthesis.tick()
  43. expect(infer).toHaveBeenCalledTimes(1)
  44. expect(runtime.knowledge.memories(owner, { scope: 'user' })[0]?.content).toBe('用户偏好简洁中文')
  45. expect(runtime.knowledge.jobs(owner)[0]?.status).toBe('complete')
  46. await synthesis.tick(); expect(infer).toHaveBeenCalledTimes(1)
  47. })
  48. it('rejects fabricated evidence and does not promote an assistant assertion into shared user memory', async () => {
  49. const { source, reply } = turn('请完成这一次的接口分析')
  50. synthesis.infer = async () => JSON.stringify({ memories: [
  51. { scope: 'agent', content: '不存在的事实', tier: 'log', sourceMessageId: source.id, quote: '证据里没有这句话' },
  52. { scope: 'user', content: '用户是架构师', tier: 'profile', sourceMessageId: reply.id, quote: reply.content },
  53. { scope: 'project', content: '无项目不应保存', tier: 'log', sourceMessageId: source.id, quote: source.content },
  54. ] })
  55. await synthesis.tick()
  56. expect(runtime.knowledge.memories(owner, { scope: 'user' })).toEqual([])
  57. expect(runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })).toEqual([])
  58. })
  59. it('honors pause during inference and retries failures without breaking the conversation', async () => {
  60. const { run } = turn()
  61. synthesis.infer = async () => { throw new Error('temporary inference outage') }
  62. await synthesis.tick()
  63. const failed = runtime.knowledge.jobs(owner)[0]!
  64. expect(failed.status).toBe('pending'); expect(failed.attempts).toBe(1)
  65. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('complete')
  66. runtime.knowledge.saveJob(owner, { ...failed, nextAt: 0 })
  67. synthesis.infer = async () => { store.updateAgent(owner, agent.id, { memoryEnabled: false }); return '{"memories":[]}' }
  68. await synthesis.tick()
  69. expect(runtime.knowledge.jobs(owner)[0]?.status).toBe('skipped')
  70. })
  71. it('does not recreate a forgotten fact by paraphrasing evidence from the same old turn', async () => {
  72. const { source } = turn()
  73. const fact = runtime.knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: agent.id, content: '偏好简洁中文', tier: 'profile', sources: [{ messageId: source.id, conversationId: source.conversationId, quote: source.content }] })
  74. runtime.knowledge.forget(owner, { requestId: randomUUID(), scope: 'agent', agentId: agent.id, id: fact.id, expectedRevision: fact.revision })
  75. synthesis.infer = async () => JSON.stringify({ memories: [{ scope: 'agent', content: '用中文简单回答用户', tier: 'profile', sourceMessageId: source.id, quote: source.content }] })
  76. await synthesis.tick()
  77. expect(runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })).toEqual([])
  78. })
  79. it('waits for asynchronous collaboration and extracts the returned result only in its originating context', async () => {
  80. const conversation = store.list<WorkspaceConversation>(owner, 'conversation')[0]!
  81. const root = runtime.send(owner, conversation.id, { requestId: randomUUID(), content: '请联系同伴核对项目规范并记住结果' })
  82. root.status = 'complete'; store.saveRun(owner, root)
  83. const peerId = randomUUID(), replyRunId = randomUUID()
  84. store.put(owner, 'collaboration-chain', root.id, { id: root.id, rootRunId: root.id, stopped: false })
  85. const peer = { id: peerId, chainId: root.id, status: 'running', conversationId: conversation.id, runId: replyRunId }
  86. store.put(owner, 'peer-message', peerId, peer)
  87. synthesis.enqueue(owner, root)
  88. expect(runtime.knowledge.jobs(owner)).toEqual([])
  89. const reply: WorkspaceRun = { ...root, id: replyRunId, triggerKind: 'peer', peerMessageId: peerId, collaborationChainId: root.id }
  90. store.saveRun(owner, reply)
  91. store.put(owner, 'peer-message', peerId, { ...peer, status: 'complete' })
  92. 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() }
  93. store.saveMessage(owner, message)
  94. synthesis.enqueue(owner, reply)
  95. expect(runtime.knowledge.jobs(owner)).toHaveLength(1)
  96. synthesis.infer = async (_owner, _agent, prompt) => {
  97. expect(prompt).toContain(message.content)
  98. return JSON.stringify({ memories: [{ scope: 'agent', content: '接口需要保持向后兼容', tier: 'log', sourceMessageId: message.id, quote: message.content }] })
  99. }
  100. await synthesis.tick()
  101. expect(runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })[0]?.content).toBe('接口需要保持向后兼容')
  102. })