Files
zclaw_openfang/tests/desktop/agent-memory.test.ts
iven 04ddf94123 feat: implement ZCLAW Agent Intelligence Evolution Phase 1-3
Phase 1: Persistent Memory + Identity Dynamic Evolution
- agent-memory.ts: MemoryManager with localStorage persistence, keyword search, deduplication, importance scoring, pruning, markdown export
- agent-identity.ts: AgentIdentityManager with per-agent SOUL/AGENTS/USER.md, change proposals with approval workflow, snapshot rollback
- memory-extractor.ts: Rule-based conversation memory extraction (Phase 1), LLM extraction prompt ready for Phase 2
- MemoryPanel.tsx: Memory browsing UI with search, type filter, delete, export (integrated as 4th tab in RightPanel)

Phase 2: Context Governance
- context-compactor.ts: Token estimation, threshold monitoring (soft/hard), memory flush before compaction, rule-based summarization
- chatStore integration: auto-compact when approaching token limits

Phase 3: Proactive Intelligence + Self-Reflection
- heartbeat-engine.ts: Periodic checks (pending tasks, memory health, idle greeting), quiet hours, proactivity levels (silent/light/standard/autonomous)
- reflection-engine.ts: Pattern analysis from memory corpus, improvement suggestions, identity change proposals, meta-memory creation

Chat Flow Integration (chatStore.ts):
- Pre-send: context compaction check -> memory search -> identity system prompt injection
- Post-complete: async memory extraction -> reflection conversation tracking -> auto-trigger reflection

Tests: 274 passing across 12 test files
- agent-memory.test.ts: 42 tests
- context-compactor.test.ts: 23 tests
- heartbeat-reflection.test.ts: 28 tests
- chatStore.test.ts: 11 tests (no regressions)

Refs: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md updated with implementation progress
2026-03-15 22:24:57 +08:00

559 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tests for Agent Memory System (Phase 1)
*
* Covers: MemoryManager, AgentIdentityManager, MemoryExtractor
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
MemoryManager,
resetMemoryManager,
type MemoryEntry,
} from '../../desktop/src/lib/agent-memory';
import {
AgentIdentityManager,
resetAgentIdentityManager,
} from '../../desktop/src/lib/agent-identity';
import {
MemoryExtractor,
resetMemoryExtractor,
} from '../../desktop/src/lib/memory-extractor';
// === Mock localStorage ===
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value; },
removeItem: (key: string) => { delete store[key]; },
clear: () => { store = {}; },
};
})();
vi.stubGlobal('localStorage', localStorageMock);
// =============================================
// MemoryManager Tests
// =============================================
describe('MemoryManager', () => {
let mgr: MemoryManager;
beforeEach(() => {
localStorageMock.clear();
resetMemoryManager();
mgr = new MemoryManager();
});
describe('save', () => {
it('saves a memory entry and assigns id/timestamps', async () => {
const entry = await mgr.save({
agentId: 'agent-1',
content: '用户喜欢简洁回答',
type: 'preference',
importance: 7,
source: 'auto',
tags: ['communication'],
});
expect(entry.id).toMatch(/^mem_/);
expect(entry.content).toBe('用户喜欢简洁回答');
expect(entry.type).toBe('preference');
expect(entry.importance).toBe(7);
expect(entry.createdAt).toBeTruthy();
expect(entry.accessCount).toBe(0);
});
it('deduplicates similar content for same agent+type', async () => {
await mgr.save({
agentId: 'agent-1',
content: '用户的公司叫 ACME',
type: 'fact',
importance: 6,
source: 'auto',
tags: ['company'],
});
// Save very similar content
const second = await mgr.save({
agentId: 'agent-1',
content: '用户的公司叫 ACME Corp',
type: 'fact',
importance: 8,
source: 'auto',
tags: ['company', 'name'],
});
// Should update existing entry, not create a new one
const all = await mgr.getAll('agent-1');
expect(all.length).toBe(1);
expect(all[0].importance).toBe(8); // Takes higher importance
expect(all[0].tags).toContain('name'); // Merged tags
});
it('does not deduplicate across different agents', async () => {
await mgr.save({
agentId: 'agent-1',
content: '用户喜欢TypeScript',
type: 'preference',
importance: 6,
source: 'auto',
tags: [],
});
await mgr.save({
agentId: 'agent-2',
content: '用户喜欢TypeScript',
type: 'preference',
importance: 6,
source: 'auto',
tags: [],
});
const all1 = await mgr.getAll('agent-1');
const all2 = await mgr.getAll('agent-2');
expect(all1.length).toBe(1);
expect(all2.length).toBe(1);
});
});
describe('search', () => {
beforeEach(async () => {
await mgr.save({ agentId: 'a1', content: '用户的项目使用 React 和 TypeScript', type: 'fact', importance: 7, source: 'auto', tags: ['tech'] });
await mgr.save({ agentId: 'a1', content: '用户喜欢简洁的代码风格', type: 'preference', importance: 6, source: 'auto', tags: ['coding'] });
await mgr.save({ agentId: 'a1', content: '飞书API需要先验证token再调用', type: 'lesson', importance: 8, source: 'auto', tags: ['feishu', 'api'] });
await mgr.save({ agentId: 'a2', content: '另一个 agent 的记忆', type: 'fact', importance: 5, source: 'auto', tags: [] });
});
it('finds relevant memories by keyword', async () => {
const results = await mgr.search('TypeScript React', { agentId: 'a1' });
expect(results.length).toBeGreaterThan(0);
expect(results[0].content).toContain('TypeScript');
});
it('filters by agentId', async () => {
const results = await mgr.search('记忆', { agentId: 'a1' });
expect(results.every(r => r.agentId === 'a1')).toBe(true);
});
it('filters by type', async () => {
const results = await mgr.search('代码', { agentId: 'a1', type: 'preference' });
expect(results.every(r => r.type === 'preference')).toBe(true);
});
it('filters by minImportance', async () => {
const results = await mgr.search('飞书 API token', { agentId: 'a1', minImportance: 7 });
expect(results.every(r => r.importance >= 7)).toBe(true);
});
it('filters by tags', async () => {
const results = await mgr.search('API', { agentId: 'a1', tags: ['feishu'] });
expect(results.length).toBeGreaterThan(0);
expect(results[0].tags).toContain('feishu');
});
it('returns empty for no match', async () => {
const results = await mgr.search('量子物理', { agentId: 'a1' });
expect(results).toHaveLength(0);
});
it('updates access metadata on search hit', async () => {
const before = await mgr.getAll('a1');
const hitEntry = before.find(e => e.content.includes('TypeScript'))!;
const beforeCount = hitEntry.accessCount;
await mgr.search('TypeScript', { agentId: 'a1' });
const after = await mgr.get(hitEntry.id);
expect(after!.accessCount).toBe(beforeCount + 1);
});
});
describe('getAll', () => {
it('returns entries for the requested agent', async () => {
await mgr.save({ agentId: 'a1', content: 'memory A', type: 'fact', importance: 5, source: 'auto', tags: [] });
await mgr.save({ agentId: 'a1', content: 'memory B', type: 'fact', importance: 5, source: 'auto', tags: [] });
await mgr.save({ agentId: 'a2', content: 'other agent', type: 'fact', importance: 5, source: 'auto', tags: [] });
const all = await mgr.getAll('a1');
expect(all).toHaveLength(2);
expect(all.every(e => e.agentId === 'a1')).toBe(true);
});
it('respects limit', async () => {
for (let i = 0; i < 5; i++) {
await mgr.save({ agentId: 'a1', content: `memory ${i}`, type: 'fact', importance: 5, source: 'auto', tags: [] });
}
const limited = await mgr.getAll('a1', { limit: 3 });
expect(limited).toHaveLength(3);
});
});
describe('forget', () => {
it('removes a specific memory', async () => {
const entry = await mgr.save({ agentId: 'a1', content: 'to forget', type: 'fact', importance: 5, source: 'auto', tags: [] });
await mgr.forget(entry.id);
expect(await mgr.get(entry.id)).toBeNull();
});
});
describe('prune', () => {
it('removes old low-importance entries', async () => {
// Create an old low-importance entry
const entry = await mgr.save({ agentId: 'a1', content: 'old unimportant', type: 'context', importance: 2, source: 'auto', tags: [] });
// Manually set lastAccessedAt to 60 days ago
const raw = JSON.parse(localStorageMock.getItem('zclaw-agent-memories')!);
const idx = raw.findIndex((e: MemoryEntry) => e.id === entry.id);
raw[idx].lastAccessedAt = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString();
localStorageMock.setItem('zclaw-agent-memories', JSON.stringify(raw));
// Reload and prune
resetMemoryManager();
const mgr2 = new MemoryManager();
const pruned = await mgr2.prune({ maxAgeDays: 30, minImportance: 3, agentId: 'a1' });
expect(pruned).toBe(1);
});
it('keeps high-importance entries even when old', async () => {
const entry = await mgr.save({ agentId: 'a1', content: 'important old', type: 'fact', importance: 9, source: 'auto', tags: [] });
const raw = JSON.parse(localStorageMock.getItem('zclaw-agent-memories')!);
const idx = raw.findIndex((e: MemoryEntry) => e.id === entry.id);
raw[idx].lastAccessedAt = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString();
localStorageMock.setItem('zclaw-agent-memories', JSON.stringify(raw));
resetMemoryManager();
const mgr2 = new MemoryManager();
const pruned = await mgr2.prune({ maxAgeDays: 30, minImportance: 3, agentId: 'a1' });
expect(pruned).toBe(0);
});
});
describe('exportToMarkdown', () => {
it('produces formatted markdown with categories', async () => {
await mgr.save({ agentId: 'a1', content: '公司叫 ACME', type: 'fact', importance: 7, source: 'auto', tags: ['company'] });
await mgr.save({ agentId: 'a1', content: '喜欢简洁回答', type: 'preference', importance: 6, source: 'auto', tags: [] });
const md = await mgr.exportToMarkdown('a1');
expect(md).toContain('Agent Memory Export');
expect(md).toContain('事实');
expect(md).toContain('偏好');
expect(md).toContain('ACME');
expect(md).toContain('简洁');
});
it('returns empty notice for agent with no memories', async () => {
const md = await mgr.exportToMarkdown('nonexistent');
expect(md).toContain('No memories recorded');
});
});
describe('stats', () => {
it('returns correct statistics', async () => {
await mgr.save({ agentId: 'a1', content: 'fact1', type: 'fact', importance: 5, source: 'auto', tags: [] });
await mgr.save({ agentId: 'a1', content: 'pref1', type: 'preference', importance: 5, source: 'auto', tags: [] });
await mgr.save({ agentId: 'a1', content: 'lesson1', type: 'lesson', importance: 5, source: 'auto', tags: [] });
const stats = await mgr.stats('a1');
expect(stats.totalEntries).toBe(3);
expect(stats.byType['fact']).toBe(1);
expect(stats.byType['preference']).toBe(1);
expect(stats.byType['lesson']).toBe(1);
});
});
describe('persistence', () => {
it('survives manager recreation (simulates app restart)', async () => {
await mgr.save({ agentId: 'a1', content: 'persistent memory', type: 'fact', importance: 8, source: 'auto', tags: [] });
// Create a new manager (simulates restart)
const mgr2 = new MemoryManager();
const all = await mgr2.getAll('a1');
expect(all.length).toBe(1);
expect(all[0].content).toBe('persistent memory');
});
});
});
// =============================================
// AgentIdentityManager Tests
// =============================================
describe('AgentIdentityManager', () => {
let idMgr: AgentIdentityManager;
beforeEach(() => {
localStorageMock.clear();
resetAgentIdentityManager();
idMgr = new AgentIdentityManager();
});
describe('getIdentity', () => {
it('returns defaults for new agent', () => {
const identity = idMgr.getIdentity('new-agent');
expect(identity.soul).toContain('ZCLAW');
expect(identity.instructions).toContain('指令');
expect(identity.userProfile).toContain('用户画像');
});
it('returns same identity on second call', () => {
const first = idMgr.getIdentity('test');
const second = idMgr.getIdentity('test');
expect(first.soul).toBe(second.soul);
});
});
describe('buildSystemPrompt', () => {
it('combines soul + instructions + memory context', () => {
const prompt = idMgr.buildSystemPrompt('test', '## 相关记忆\n- 用户喜欢中文');
expect(prompt).toContain('ZCLAW');
expect(prompt).toContain('指令');
expect(prompt).toContain('相关记忆');
});
it('excludes default user profile', () => {
const prompt = idMgr.buildSystemPrompt('test');
expect(prompt).not.toContain('尚未收集到');
});
it('includes updated user profile', () => {
idMgr.updateUserProfile('test', '## 用户偏好\n- 喜欢TypeScript');
const prompt = idMgr.buildSystemPrompt('test');
expect(prompt).toContain('TypeScript');
});
});
describe('updateUserProfile', () => {
it('updates profile and creates snapshot', () => {
idMgr.updateUserProfile('test', '新的用户画像');
const identity = idMgr.getIdentity('test');
expect(identity.userProfile).toBe('新的用户画像');
const snapshots = idMgr.getSnapshots('test');
expect(snapshots.length).toBeGreaterThan(0);
});
});
describe('appendToUserProfile', () => {
it('appends content to existing profile', () => {
idMgr.appendToUserProfile('test', '- 新发现的偏好');
const identity = idMgr.getIdentity('test');
expect(identity.userProfile).toContain('新发现的偏好');
});
});
describe('change proposals', () => {
it('creates a pending proposal', () => {
const proposal = idMgr.proposeChange('test', 'soul', '新的人格定义', '根据用户反馈调整');
expect(proposal.status).toBe('pending');
expect(proposal.suggestedContent).toBe('新的人格定义');
const pending = idMgr.getPendingProposals('test');
expect(pending).toHaveLength(1);
});
it('approves a proposal and updates identity', () => {
const proposal = idMgr.proposeChange('test', 'soul', '新的 SOUL', '改进');
idMgr.approveProposal(proposal.id);
const identity = idMgr.getIdentity('test');
expect(identity.soul).toBe('新的 SOUL');
const pending = idMgr.getPendingProposals('test');
expect(pending).toHaveLength(0);
});
it('rejects a proposal without changing identity', () => {
const oldIdentity = idMgr.getIdentity('test');
const proposal = idMgr.proposeChange('test', 'soul', '被拒绝的内容', '原因');
idMgr.rejectProposal(proposal.id);
const identity = idMgr.getIdentity('test');
expect(identity.soul).toBe(oldIdentity.soul);
});
});
describe('snapshots and rollback', () => {
it('can rollback to a previous snapshot', () => {
idMgr.updateUserProfile('test', 'version 1');
idMgr.updateUserProfile('test', 'version 2');
const snapshots = idMgr.getSnapshots('test');
expect(snapshots.length).toBeGreaterThanOrEqual(2);
// snapshots[0] = most recent (taken before 'version 2' was set, contains 'version 1')
const snapshotWithV1 = snapshots[0];
idMgr.restoreSnapshot('test', snapshotWithV1.id);
const identity = idMgr.getIdentity('test');
expect(identity.userProfile).toBe('version 1');
});
});
describe('direct edit', () => {
it('updates file and creates snapshot', () => {
idMgr.updateFile('test', 'instructions', '# 自定义指令\n\n新指令内容');
const identity = idMgr.getIdentity('test');
expect(identity.instructions).toContain('自定义指令');
});
});
describe('persistence', () => {
it('survives recreation', () => {
idMgr.updateUserProfile('test', '持久化测试');
resetAgentIdentityManager();
const mgr2 = new AgentIdentityManager();
const identity = mgr2.getIdentity('test');
expect(identity.userProfile).toBe('持久化测试');
});
});
});
// =============================================
// MemoryExtractor Tests
// =============================================
describe('MemoryExtractor', () => {
let extractor: MemoryExtractor;
beforeEach(() => {
localStorageMock.clear();
resetMemoryManager();
resetAgentIdentityManager();
resetMemoryExtractor();
extractor = new MemoryExtractor();
});
describe('extractFromConversation', () => {
it('skips extraction for short conversations', async () => {
const result = await extractor.extractFromConversation(
[
{ role: 'user', content: '你好' },
{ role: 'assistant', content: '你好!' },
],
'agent-1'
);
expect(result.saved).toBe(0);
});
it('extracts facts from user messages', async () => {
const result = await extractor.extractFromConversation(
[
{ role: 'user', content: '我的公司叫字节跳动' },
{ role: 'assistant', content: '好的,了解了。' },
{ role: 'user', content: '我在做一个AI助手项目' },
{ role: 'assistant', content: '听起来很有趣!' },
{ role: 'user', content: '帮我看看这个bug' },
{ role: 'assistant', content: '好的,让我看看。' },
],
'agent-1'
);
expect(result.items.length).toBeGreaterThan(0);
const facts = result.items.filter(i => i.type === 'fact');
expect(facts.length).toBeGreaterThan(0);
});
it('extracts preferences from user messages', async () => {
const result = await extractor.extractFromConversation(
[
{ role: 'user', content: '请用中文回复我' },
{ role: 'assistant', content: '好的,我会用中文。' },
{ role: 'user', content: '我喜欢简洁的代码风格' },
{ role: 'assistant', content: '明白了。' },
{ role: 'user', content: '以后都用TypeScript写' },
{ role: 'assistant', content: '好的。' },
],
'agent-1'
);
const prefs = result.items.filter(i => i.type === 'preference');
expect(prefs.length).toBeGreaterThan(0);
});
it('saves extracted memories to MemoryManager', async () => {
await extractor.extractFromConversation(
[
{ role: 'user', content: '我的项目叫ZCLAW' },
{ role: 'assistant', content: '好的。' },
{ role: 'user', content: '我喜欢简洁回答' },
{ role: 'assistant', content: '了解。' },
{ role: 'user', content: '继续' },
{ role: 'assistant', content: '好的。' },
],
'agent-1'
);
const memMgr = new MemoryManager();
const all = await memMgr.getAll('agent-1');
expect(all.length).toBeGreaterThan(0);
});
it('respects extraction cooldown', async () => {
const msgs = [
{ role: 'user', content: '我的公司叫字节跳动' },
{ role: 'assistant', content: '好的。' },
{ role: 'user', content: '帮我写代码' },
{ role: 'assistant', content: '好的。' },
{ role: 'user', content: '继续' },
{ role: 'assistant', content: '好的。' },
];
const r1 = await extractor.extractFromConversation(msgs, 'agent-1');
const r2 = await extractor.extractFromConversation(msgs, 'agent-1');
// Second call should be blocked by cooldown
expect(r2.saved).toBe(0);
// First call should have extracted something
expect(r1.items.length).toBeGreaterThanOrEqual(0); // May or may not match patterns
});
});
describe('buildExtractionPrompt', () => {
it('produces a valid LLM prompt', () => {
const prompt = extractor.buildExtractionPrompt([
{ role: 'user', content: '帮我配置飞书' },
{ role: 'assistant', content: '好的需要app_id和app_secret' },
]);
expect(prompt).toContain('请从以下对话中提取');
expect(prompt).toContain('用户');
expect(prompt).toContain('助手');
expect(prompt).toContain('飞书');
});
});
describe('parseExtractionResponse', () => {
it('parses valid JSON response', () => {
const response = `[
{"content": "用户喜欢中文", "type": "preference", "importance": 7, "tags": ["lang"]},
{"content": "公司叫ACME", "type": "fact", "importance": 6, "tags": ["company"]}
]`;
const items = extractor.parseExtractionResponse(response);
expect(items).toHaveLength(2);
expect(items[0].type).toBe('preference');
expect(items[1].type).toBe('fact');
});
it('handles wrapped JSON in markdown', () => {
const response = "```json\n[{\"content\": \"test\", \"type\": \"fact\", \"importance\": 5, \"tags\": []}]\n```";
const items = extractor.parseExtractionResponse(response);
expect(items).toHaveLength(1);
});
it('returns empty for invalid response', () => {
const items = extractor.parseExtractionResponse('no json here');
expect(items).toHaveLength(0);
});
it('clamps importance to 1-10', () => {
const response = '[{"content": "test", "type": "fact", "importance": 15, "tags": []}]';
const items = extractor.parseExtractionResponse(response);
expect(items[0].importance).toBe(10);
});
});
});