workspaceKnowledge.test.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. // @vitest-environment node
  2. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  3. import { mkdtempSync, readFileSync, rmSync, writeFileSync } 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 { WorkspaceKnowledge } from '../../src/server/workspaceKnowledge'
  9. import type { WorkspaceAgent, WorkspaceMessage } from '../../src/shared/workspace'
  10. let home: string, store: WorkspaceStore, knowledge: WorkspaceKnowledge, a: WorkspaceAgent, b: WorkspaceAgent
  11. const owner = 'memory-owner'
  12. beforeEach(() => {
  13. home = mkdtempSync(join(tmpdir(), 'yaoyao-file-memory-'))
  14. store = new WorkspaceStore(home); knowledge = new WorkspaceKnowledge(home, store)
  15. a = store.createAgent(owner, { name: '甲', profile: 'same-profile', canManageTeam: true })
  16. b = store.createAgent(owner, { name: '乙', profile: 'same-profile' })
  17. })
  18. afterEach(() => { store.close(); rmSync(home, { recursive: true, force: true }) })
  19. function source(content: string) {
  20. const conversation = store.list<any>(owner, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === a.id)
  21. const message: WorkspaceMessage = { id: randomUUID(), conversationId: conversation.id, seq: 0, role: 'user', content, reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: Date.now() }
  22. store.saveMessage(owner, message)
  23. return { messageId: message.id, conversationId: message.conversationId, quote: content }
  24. }
  25. describe('Bot file memory', () => {
  26. it('preserves different shared contributions instead of overwriting the same topic', async () => {
  27. for (const [agent, content] of [[a, '接口使用版本一'], [b, '接口使用版本二']] as const) await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'user', agentId: agent.id, topic: '接口版本', content, tier: 'profile' })
  28. const memories = await knowledge.memories(owner, { scope: 'user' })
  29. expect(memories).toHaveLength(2)
  30. expect(memories.every(m => m.conflict)).toBe(true)
  31. expect(new Set(memories.map(m => m.agentId)).size).toBe(2)
  32. })
  33. it('keeps profile and log in the standing prompt and leaves notes out', async () => {
  34. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, content: '基础职责', tier: 'profile' })
  35. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, content: '昨天做过的事', tier: 'log' })
  36. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, content: '马上会过期', tier: 'note' })
  37. const text = (await knowledge.context(owner, a.id)).text
  38. expect(text).toContain('基础职责')
  39. expect(text).toContain('昨天做过的事')
  40. expect(text).not.toContain('马上会过期')
  41. })
  42. it('isolates bots sharing one Profile and merges only shared user contributions', async () => {
  43. const fact = source('以后请使用中文回答')
  44. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'agent', agentId: a.id, content: '甲自己的经验', tier: 'profile' })
  45. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'user', agentId: a.id, content: '用户偏好中文', tier: 'profile', sources: [fact] }, { agentId: a.id })
  46. expect((await knowledge.context(owner, a.id)).text).toContain('甲自己的经验')
  47. expect((await knowledge.context(owner, b.id)).text).not.toContain('甲自己的经验')
  48. expect((await knowledge.context(owner, b.id)).text).toContain('用户偏好中文')
  49. expect((await knowledge.context(owner, a.id)).text).toContain('以自己的记忆为准')
  50. await expect(knowledge.memories(owner, { scope: 'agent', agentId: a.id }, { agentId: b.id })).rejects.toThrowError(expect.objectContaining({ code: 'memory_forbidden' }))
  51. await expect(knowledge.memories('other-owner', { scope: 'agent', agentId: a.id })).resolves.toEqual([])
  52. const body = readFileSync(join(home, 'bot-workspace', owner, 'agents', a.id, 'memory', 'profile.md'), 'utf8')
  53. expect(body).toMatch(/- \(\d{4}-\d{2}-\d{2}\) 甲自己的经验/)
  54. })
  55. it('keeps files authoritative and rebuilds project membership and group projections', async () => {
  56. const group = store.createGroup(owner, { name: '群', memberIds: [a.id, b.id], collaborationMode: 'discussion' })
  57. const requestId = randomUUID(), input = { requestId, name: '项目一', description: '共同工作', memberIds: [a.id, b.id], groupIds: [group.id] }
  58. const project = knowledge.saveProject(owner, input)
  59. expect(knowledge.saveProject(owner, input)).toEqual(project)
  60. store.remove(owner, 'project', project.id)
  61. const c = store.require<any>(owner, 'conversation', group.id); delete c.projectId; store.put(owner, 'conversation', c.id, c)
  62. knowledge = new WorkspaceKnowledge(home, store)
  63. expect(store.require<any>(owner, 'project', project.id).memberIds).toEqual([a.id, b.id].sort())
  64. expect(store.require<any>(owner, 'conversation', group.id).projectId).toBe(project.id)
  65. const fact = source('本项目使用版本化接口')
  66. await knowledge.writeMemory(owner, { requestId: randomUUID(), scope: 'project', projectId: project.id, agentId: a.id, content: '接口需要版本号', tier: 'profile', sources: [fact] }, { agentId: a.id })
  67. expect((await knowledge.context(owner, b.id, project.id)).text).toContain('接口需要版本号')
  68. expect((await knowledge.context(owner, b.id)).text).not.toContain('接口需要版本号')
  69. knowledge.saveProject(owner, { ...input, requestId: randomUUID(), id: project.id, expectedRevision: project.revision, memberIds: [a.id], groupIds: [] })
  70. await expect(knowledge.context(owner, b.id, project.id)).rejects.toThrowError(expect.objectContaining({ code: 'project_forbidden' }))
  71. })
  72. it('rejects stale revisions, cross-contributor writes and overwrite of user-maintained facts', async () => {
  73. const fact = source('以后请精简回复')
  74. const input = { requestId: randomUUID(), scope: 'user' as const, agentId: a.id, content: '用户喜欢简洁', tier: 'profile' as const, sources: [fact] }
  75. const initial = await knowledge.writeMemory(owner, input, { agentId: a.id })
  76. await expect(knowledge.writeMemory(owner, { ...input, requestId: randomUUID() }, { agentId: b.id })).rejects.toThrowError(expect.objectContaining({ code: 'memory_forbidden' }))
  77. const edited = await knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), id: initial.id, expectedRevision: 1, content: '回复简洁但保留依据' })
  78. expect(edited.revision).toBe(2)
  79. await expect(knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), id: initial.id, expectedRevision: 1 })).rejects.toThrowError(expect.objectContaining({ code: 'memory_revision_conflict' }))
  80. await expect(knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), id: initial.id, expectedRevision: 2 }, { agentId: a.id })).rejects.toThrowError(expect.objectContaining({ code: 'memory_manual_protected' }))
  81. expect(await knowledge.revisions(owner, input, initial.id)).toHaveLength(2)
  82. })
  83. it('keeps tombstones across restart and prevents old synthesis from resurrecting forgotten facts', async () => {
  84. const input = { requestId: randomUUID(), scope: 'agent' as const, agentId: a.id, content: '旧偏好', tier: 'profile' as const }
  85. const memory = await knowledge.writeMemory(owner, input)
  86. await knowledge.forget(owner, { ...input, requestId: randomUUID(), id: memory.id, expectedRevision: memory.revision })
  87. knowledge = new WorkspaceKnowledge(home, store)
  88. await expect(knowledge.writeMemory(owner, { ...input, requestId: randomUUID(), automatic: true, sources: [source('旧偏好')] }, { agentId: a.id })).rejects.toThrowError(expect.objectContaining({ code: 'memory_forgotten' }))
  89. expect(await knowledge.memories(owner, input)).toEqual([])
  90. expect((await knowledge.revisions(owner, input, memory.id))[0]?.operation).toBe('forget')
  91. })
  92. it('recovers a partially written multi-file transaction and does not duplicate its receipt or event', async () => {
  93. const input = { requestId: randomUUID(), scope: 'agent' as const, agentId: a.id, content: '需要恢复的事实', tier: 'profile' as const }
  94. knowledge.fault = path => { if (path.endsWith('profile.md')) throw new Error('simulated power loss') }
  95. await expect(knowledge.writeMemory(owner, input)).rejects.toThrow('simulated power loss')
  96. knowledge = new WorkspaceKnowledge(home, store)
  97. const restored = await knowledge.memories(owner, input)
  98. expect(restored).toHaveLength(1)
  99. expect(await knowledge.writeMemory(owner, input)).toEqual(restored[0])
  100. expect(store.events(owner, 0).filter(e => e.type === 'memory.changed')).toHaveLength(1)
  101. expect(await knowledge.revisions(owner, input, restored[0]!.id)).toHaveLength(1)
  102. })
  103. it('reports corrupted metadata without silently replacing it and rejects unsafe paths', async () => {
  104. const input = { requestId: randomUUID(), scope: 'agent' as const, agentId: a.id, content: '保留事实', tier: 'log' as const }
  105. await knowledge.writeMemory(owner, input)
  106. const path = join(home, 'bot-workspace', owner, 'agents', a.id, 'memory', '.dreaming', 'records.json')
  107. writeFileSync(path, '{broken')
  108. await expect(knowledge.memories(owner, input)).rejects.toThrowError(expect.objectContaining({ code: 'knowledge_file_corrupt' }))
  109. expect(readFileSync(path, 'utf8')).toBe('{broken')
  110. await expect(knowledge.memories(owner, { scope: 'project', projectId: '../other' })).rejects.toThrowError(expect.objectContaining({ code: 'knowledge_invalid_id' }))
  111. })
  112. })