workspaceMemorySynthesis.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  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('rotates accounts even when the first account always has eligible memory work', async () => {
  33. vi.spyOn(store, 'owners').mockReturnValue(['first', 'second', 'inactive', '_internal'])
  34. vi.spyOn(runtime, 'userActive').mockImplementation(account => account !== 'inactive')
  35. vi.spyOn(runtime.knowledge, 'jobs').mockImplementation(account => [{
  36. id: `${account}-job`, status: 'pending', nextAt: 0, createdAt: account === 'first' ? 100 : 0,
  37. }] as any)
  38. const run = vi.spyOn(synthesis, 'run').mockResolvedValue()
  39. for (let index = 0; index < 6; index++) await synthesis.tick()
  40. expect(run.mock.calls.map(([account]) => account)).toEqual(['first', 'second', 'first', 'second', 'first', 'second'])
  41. })
  42. it('does not reserve a fairness turn for accounts without due jobs and handles a removed account', async () => {
  43. const owners = vi.spyOn(store, 'owners').mockReturnValue(['first', 'second'])
  44. vi.spyOn(runtime.knowledge, 'jobs').mockImplementation(account => [{
  45. id: `${account}-job`, status: 'pending', nextAt: account === 'first' ? Date.now() + 60_000 : 0, createdAt: 0,
  46. }] as any)
  47. const run = vi.spyOn(synthesis, 'run').mockResolvedValue()
  48. await synthesis.tick()
  49. owners.mockReturnValue(['third'])
  50. await synthesis.tick()
  51. expect(run.mock.calls.map(([account]) => account)).toEqual(['second', 'third'])
  52. })
  53. it('runs extraction on server Hermes even for a legacy computer Bot',async()=>{
  54. const request=vi.fn(async()=>({status:200,body:Buffer.from(JSON.stringify({text:'{"memories":[]}'}))}))
  55. const target={session:{request}}
  56. runtime.nodes.target=vi.fn(()=>target) as any
  57. runtime.nodes.targetForAgent=vi.fn(()=>{throw new Error('VM must not run inference')})
  58. vi.spyOn(runtime,'botCapabilities').mockResolvedValue({memory:true,extraction:true} as any)
  59. await synthesis.infer(owner,{...agent,execution:'computer'},'extract facts')
  60. expect(runtime.nodes.target).toHaveBeenCalledWith(owner,agent.nodeId)
  61. expect(runtime.nodes.targetForAgent).not.toHaveBeenCalled()
  62. })
  63. 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 }) => {
  64. const { source, reply, run } = turn(content)
  65. const project = runtime.knowledge.saveProject(owner, { requestId: randomUUID(), name: '项目', description: '', memberIds: [agent.id], groupIds: [] })
  66. run.projectId = project.id; store.saveRun(owner, run)
  67. const job = runtime.knowledge.jobs(owner)[0]!
  68. runtime.knowledge.saveJob(owner, { ...job, projectId: project.id })
  69. for (const scope of ['agent', 'user', 'project'] as const) {
  70. for (let index = 0; index < 6; index++) await runtime.knowledge.writeMemory(owner, {
  71. requestId: randomUUID(), scope, agentId: agent.id, projectId: scope === 'project' ? project.id : undefined,
  72. content: `${scope}-${index}:`.padEnd(2000, '甲'), tier: 'profile',
  73. })
  74. }
  75. for (let index = 0; index < 6; index++) store.saveMessage(owner, { ...reply, id: randomUUID(), seq: 0, content })
  76. const infer = vi.fn(async (_owner, _agent, prompt: string) => {
  77. expect(prompt.length).toBeLessThanOrEqual(MEMORY_EXTRACTION_MAX_CHARS)
  78. const serialized = prompt.slice(prompt.indexOf('本轮证据:') + '本轮证据:'.length)
  79. const evidence = JSON.parse(serialized)
  80. expect(evidence[0].id).toBe(source.id)
  81. expect(evidence[0].content.length).toBeGreaterThan(0)
  82. expect(evidence.every((message: { content: string }) => source.content.includes(message.content) || message.content === reply.content)).toBe(true)
  83. expect(prompt).not.toContain('private-execution-text')
  84. return '{"memories":[]}'
  85. })
  86. synthesis.infer = infer
  87. await synthesis.tick()
  88. expect(infer).toHaveBeenCalledOnce()
  89. expect(runtime.knowledge.jobs(owner)[0]?.status).toBe('complete')
  90. })
  91. it('merges one agent’s visible results per user request and retains pending jobs across restart', async () => {
  92. const { run, source, reply } = turn()
  93. store.saveMessage(owner, { ...reply, id: randomUUID(), seq: 0, content: '另一条公开结果' })
  94. synthesis.enqueue(owner, run)
  95. expect(runtime.knowledge.jobs(owner)).toHaveLength(1)
  96. synthesis.close(); synthesis = new WorkspaceMemorySynthesis(runtime)
  97. const infer = vi.fn(async (_owner, _agent, prompt: string) => {
  98. expect(prompt).not.toContain('private-execution-text'); expect(prompt).not.toContain('private-tool')
  99. return JSON.stringify({ memories: [{ scope: 'user', content: '用户偏好简洁中文', tier: 'profile', sourceMessageId: source.id, quote: source.content }] })
  100. })
  101. synthesis.infer = infer
  102. await synthesis.tick()
  103. expect(infer).toHaveBeenCalledTimes(1)
  104. expect((await runtime.knowledge.memories(owner, { scope: 'user' }))[0]?.content).toBe('用户偏好简洁中文')
  105. expect(runtime.knowledge.jobs(owner)[0]?.status).toBe('complete')
  106. await synthesis.tick(); expect(infer).toHaveBeenCalledTimes(1)
  107. })
  108. it('rejects fabricated evidence and does not promote an assistant assertion into shared user memory', async () => {
  109. const { source, reply } = turn('请完成这一次的接口分析')
  110. synthesis.infer = async () => JSON.stringify({ memories: [
  111. { scope: 'agent', content: '不存在的事实', tier: 'log', sourceMessageId: source.id, quote: '证据里没有这句话' },
  112. { scope: 'user', content: '用户是架构师', tier: 'profile', sourceMessageId: reply.id, quote: reply.content },
  113. { scope: 'project', content: '无项目不应保存', tier: 'log', sourceMessageId: source.id, quote: source.content },
  114. ] })
  115. await synthesis.tick()
  116. expect(await runtime.knowledge.memories(owner, { scope: 'user' })).toEqual([])
  117. expect(await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })).toEqual([])
  118. expect(runtime.knowledge.jobs(owner)[0]?.result).toMatchObject({
  119. extractedCount: 3, writtenCount: 0, duplicateCount: 0, replayedCount: 0,
  120. skippedReasons: { quote_mismatch: 1, user_not_explicit: 1, project_unbound: 1 },
  121. })
  122. })
  123. it('reports an empty extraction separately from saved memories', async () => {
  124. turn('检查今天是否有新邮件')
  125. synthesis.infer = async () => '{"memories":[]}'
  126. const write = vi.spyOn(runtime.knowledge, 'writeMemoryWithResult')
  127. await synthesis.tick()
  128. expect(write).not.toHaveBeenCalled()
  129. expect(runtime.knowledge.jobs(owner)[0]).toMatchObject({ status: 'complete', result: {
  130. extractedCount: 0, writtenCount: 0, duplicateCount: 0, replayedCount: 0, skippedReasons: {},
  131. } })
  132. })
  133. it('retains confirmed Bot working context without a project and reports duplicate facts', async () => {
  134. const { source } = turn('开发机是 studio,项目文件位于 /workspace/app;服务端部署在 server。')
  135. const fact = { scope: 'agent', content: 'app 项目在 studio 的 /workspace/app 开发,服务端部署在 server。', tier: 'log', sourceMessageId: source.id, quote: source.content }
  136. synthesis.infer = async () => JSON.stringify({ memories: [fact, fact] })
  137. await synthesis.tick()
  138. const memories = await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })
  139. expect(memories).toHaveLength(1)
  140. expect(memories[0]).toMatchObject({ content: fact.content, scope: 'agent', tier: 'log', origin: 'synthesis' })
  141. expect((await runtime.knowledge.context(owner, agent.id)).text).toContain(fact.content)
  142. expect(runtime.knowledge.jobs(owner)[0]?.result).toMatchObject({ extractedCount: 2, writtenCount: 1, duplicateCount: 1 })
  143. })
  144. it('preserves partial results on a write failure and identifies replayed writes on retry', async () => {
  145. const { source } = turn('app 在开发机编译;部署时需要使用服务器。')
  146. synthesis.infer = async () => JSON.stringify({ memories: ['app 在开发机编译', 'app 部署时使用服务器'].map(content => ({
  147. scope: 'agent', tier: 'log', content, sourceMessageId: source.id, quote: source.content,
  148. })) })
  149. const write = runtime.knowledge.writeMemoryWithResult.bind(runtime.knowledge)
  150. let calls = 0
  151. vi.spyOn(runtime.knowledge, 'writeMemoryWithResult').mockImplementation((...args) => {
  152. if (++calls === 2) throw new HttpError(502, 'OpenViking 暂时不可用', 'openviking_unavailable')
  153. return write(...args)
  154. })
  155. await synthesis.tick()
  156. const pending = runtime.knowledge.jobs(owner)[0]!
  157. expect(pending).toMatchObject({ status: 'pending', result: { extractedCount: 2, writtenCount: 1, replayedCount: 0 }, error: 'OpenViking 暂时不可用' })
  158. runtime.knowledge.saveJob(owner, { ...pending, nextAt: 0 })
  159. await synthesis.tick()
  160. expect(runtime.knowledge.jobs(owner)[0]).toMatchObject({ status: 'complete', result: { extractedCount: 2, writtenCount: 1, replayedCount: 1, duplicateCount: 0 } })
  161. expect(await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })).toHaveLength(2)
  162. })
  163. it('honors pause during inference and retries failures without breaking the conversation', async () => {
  164. const { run } = turn()
  165. synthesis.infer = async () => { throw new Error('temporary inference outage') }
  166. await synthesis.tick()
  167. const failed = runtime.knowledge.jobs(owner)[0]!
  168. expect(failed.status).toBe('pending'); expect(failed.attempts).toBe(1)
  169. expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('complete')
  170. runtime.knowledge.saveJob(owner, { ...failed, nextAt: 0 })
  171. synthesis.infer = async () => { store.updateAgent(owner, agent.id, { memoryEnabled: false }); return '{"memories":[]}' }
  172. await synthesis.tick()
  173. expect(runtime.knowledge.jobs(owner)[0]?.status).toBe('skipped')
  174. })
  175. it('does not recreate a forgotten fact by paraphrasing evidence from the same old turn', async () => {
  176. const { source } = turn()
  177. 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 }] })
  178. await runtime.knowledge.forget(owner, { requestId: randomUUID(), scope: 'agent', agentId: agent.id, id: fact.id, expectedRevision: fact.revision })
  179. synthesis.infer = async () => JSON.stringify({ memories: [{ scope: 'agent', content: '用中文简单回答用户', tier: 'profile', sourceMessageId: source.id, quote: source.content }] })
  180. await synthesis.tick()
  181. expect(await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id })).toEqual([])
  182. expect(runtime.knowledge.jobs(owner)[0]?.result?.skippedReasons).toEqual({ forgotten: 1 })
  183. })
  184. it('waits for asynchronous collaboration and extracts the returned result only in its originating context', async () => {
  185. const conversation = store.list<WorkspaceConversation>(owner, 'conversation')[0]!
  186. const root = runtime.send(owner, conversation.id, { requestId: randomUUID(), content: '请联系同伴核对项目规范并记住结果' })
  187. root.status = 'complete'; store.saveRun(owner, root)
  188. const peerId = randomUUID(), replyRunId = randomUUID()
  189. store.put(owner, 'collaboration-chain', root.id, { id: root.id, rootRunId: root.id, stopped: false })
  190. const peer = { id: peerId, chainId: root.id, status: 'running', conversationId: conversation.id, runId: replyRunId }
  191. store.put(owner, 'peer-message', peerId, peer)
  192. synthesis.enqueue(owner, root)
  193. expect(runtime.knowledge.jobs(owner)).toEqual([])
  194. const reply: WorkspaceRun = { ...root, id: replyRunId, triggerKind: 'peer', peerMessageId: peerId, collaborationChainId: root.id }
  195. store.saveRun(owner, reply)
  196. store.put(owner, 'peer-message', peerId, { ...peer, status: 'complete' })
  197. 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() }
  198. store.saveMessage(owner, message)
  199. synthesis.enqueue(owner, reply)
  200. expect(runtime.knowledge.jobs(owner)).toHaveLength(1)
  201. synthesis.infer = async (_owner, _agent, prompt) => {
  202. expect(prompt).toContain(message.content)
  203. return JSON.stringify({ memories: [{ scope: 'agent', content: '接口需要保持向后兼容', tier: 'log', sourceMessageId: message.id, quote: message.content }] })
  204. }
  205. await synthesis.tick()
  206. expect((await runtime.knowledge.memories(owner, { scope: 'agent', agentId: agent.id }))[0]?.content).toBe('接口需要保持向后兼容')
  207. })