test-memory-e2e.js 16.3 KB
#!/usr/bin/env node
/**
 * Memory System — 完整端到端测试
 *
 * 用真实 Agent 对话验证三个 Phase 的完整效果:
 *
 * Phase 1 验证:
 *   ① 对话完成 → short-term/ 自动出现 session 文件
 *   ② memory.md 自动追加关键事实
 *   ③ 第二次对话 → Agent 能引用第一次的内容(近期记忆注入)
 *
 * Phase 2 验证:
 *   ④ 保存小脑方案 → hippocampus-index.json 更新
 *   ⑤ 发送匹配任务 → 海马体命中方案 → prompt 体积增大
 *   ⑥ 使用次数自动递增
 *
 * Phase 3 验证:
 *   ⑦ 复杂任务(多 tool call)→ 自动触发反思 → reflections/ 出现日志
 *   ⑧ memory.md "已验证模式" / "失败教训" 段被更新
 *   ⑨ 过期 short-term 合并 → long-term/ 出现合并记录
 *   ⑩ 日报 API → daily/{today}.md 生成
 */

const http = require('http');
const fs = require('fs');
const path = require('path');

const BASE = 'http://localhost:3000';
const AGENT = 'poet'; // 用 poet 做测试(轻量级、不影响 ceo 数据)
const DATA_DIR = path.resolve(__dirname, '../../data/agents');
const AGENT_DIR = path.join(DATA_DIR, AGENT);
const MEM = path.join(AGENT_DIR, 'memory');

let passed = 0, failed = 0, total = 0;

function test(condition, msg) {
  total++;
  if (condition) { passed++; console.log(`  ✅ ${msg}`); }
  else { failed++; console.log(`  ❌ FAIL: ${msg}`); }
}

// ── HTTP 工具 ──
function post(urlStr, body) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const u = new URL(urlStr);
    const req = http.request({
      hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
    }, res => {
      let buf = '';
      res.on('data', c => buf += c);
      res.on('end', () => { try { resolve(JSON.parse(buf)); } catch { resolve(buf); } });
    });
    req.on('error', reject);
    req.write(data);
    req.end();
  });
}

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

function waitForStatus(agentId, convId, timeoutMs = 120000) {
  const start = Date.now();
  return new Promise((resolve, reject) => {
    const check = () => {
      if (Date.now() - start > timeoutMs) return reject(new Error(`Timeout waiting for ${convId}`));
      try {
        const metaPath = path.join(DATA_DIR, agentId, 'conversations', convId, 'meta.json');
        const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
        if (meta.status === 'completed' || meta.status === 'failed') return resolve(meta);
      } catch {}
      setTimeout(check, 2000);
    };
    check();
  });
}

function readFile(p) { try { return fs.readFileSync(p, 'utf-8'); } catch { return ''; } }
function listDir(p) { try { return fs.readdirSync(p); } catch { return []; } }

// ════════════════════════════════════════
//  Clean Slate
// ════════════════════════════════════════
function cleanMemory() {
  const dirs = ['short-term', 'long-term', 'reflections', 'cerebellum', 'daily'];
  for (const d of dirs) {
    const dir = path.join(MEM, d);
    if (fs.existsSync(dir)) {
      for (const f of fs.readdirSync(dir)) {
        if (f === '.' || f === '..') continue;
        try { fs.unlinkSync(path.join(dir, f)); } catch {}
      }
    }
  }
  // 重置 memory.md
  fs.writeFileSync(path.join(MEM, 'memory.md'),
    '# 我的记忆\n\n## 关键事实\n(尚无记录)\n\n## 已验证的模式\n(尚无记录)\n\n## 失败教训\n(尚无记录)\n', 'utf-8');
  // 清 hippocampus
  const hippIdx = path.join(MEM, 'hippocampus-index.json');
  if (fs.existsSync(hippIdx)) fs.unlinkSync(hippIdx);
}

// ════════════════════════════════════════
//  Main Test
// ════════════════════════════════════════
async function main() {
  console.log('\n🧠 Memory System — 端到端集成测试');
  console.log('═'.repeat(56));
  console.log(`  Agent: ${AGENT}`);
  console.log(`  Server: ${BASE}`);
  console.log('═'.repeat(56));

  // ── 0. Clean ──
  console.log('\n🗑️  Step 0: 清理历史数据');
  cleanMemory();
  console.log('  Done.\n');

  // ═══════════════════════════════════
  //  PHASE 1: 基础记忆读写
  // ═══════════════════════════════════
  console.log('┌──────────────────────────────────┐');
  console.log('│  PHASE 1: 基础记忆读写           │');
  console.log('└──────────────────────────────────┘');

  // ── 1a. 第一次对话 ──
  console.log('\n📋 1a. 发送第一次对话:让 Poet 写一首关于春天的五言绝句');
  const r1 = await post(`${BASE}/api/agent/task/dispatch`, {
    from: 'master', to: AGENT,
    task: '请写一首关于春天的五言绝句,主题是踏青。不需要使用任何工具,直接创作即可。',
    priority: 'P2', autoExecute: true,
  });
  test(r1.success, `Dispatch 成功 (taskId: ${r1.taskId})`);
  const conv1 = r1.executionConvId;

  console.log('  ⏳ 等待对话完成...');
  const meta1 = await waitForStatus(AGENT, conv1);
  test(meta1.status === 'completed', `对话完成 (toolCalls: ${meta1.stats?.toolCallCount})`);
  await sleep(2000);

  // ① session 文件
  const sessions1 = listDir(path.join(MEM, 'short-term')).filter(f => f.startsWith('session-'));
  test(sessions1.length >= 1, `① short-term/ 有 ${sessions1.length} 个 session 文件`);

  // ② memory.md 更新
  const mem1 = readFile(path.join(MEM, 'memory.md'));
  const hasFact = mem1.includes('[2026-') || !mem1.includes('(尚无记录)');
  test(hasFact, `② memory.md 已更新关键事实`);

  // ── 1b. 第二次对话(验证近期记忆注入) ──
  console.log('\n📋 1b. 发送第二次对话:要求 Agent 回忆之前写了什么');
  const r2 = await post(`${BASE}/api/agent/task/dispatch`, {
    from: 'master', to: AGENT,
    task: '你还记得刚才写了什么内容吗?如果你的近期记忆中有相关信息,请引用它回答。不需要使用工具。',
    priority: 'P2', autoExecute: true,
  });
  test(r2.success, `Dispatch 成功 (taskId: ${r2.taskId})`);
  const conv2 = r2.executionConvId;

  console.log('  ⏳ 等待对话完成...');
  const meta2 = await waitForStatus(AGENT, conv2);
  test(meta2.status === 'completed', `对话完成`);
  await sleep(2000);

  // ③ 验证 Agent 引用了之前的内容
  const log2Path = path.join(AGENT_DIR, 'conversations', conv2, 'messages.log');
  const log2 = readFile(log2Path);
  const remembered = log2.includes('春') || log2.includes('踏青') || log2.includes('诗') || log2.includes('记忆');
  test(remembered, `③ Agent 在第二次对话中引用了之前的春天/踏青内容`);

  const sessions2 = listDir(path.join(MEM, 'short-term')).filter(f => f.startsWith('session-'));
  test(sessions2.length >= 2, `short-term/ 现在有 ${sessions2.length} 个 session 文件`);

  // ═══════════════════════════════════
  //  PHASE 2: 海马体检索 + 小脑
  // ═══════════════════════════════════
  console.log('\n┌──────────────────────────────────┐');
  console.log('│  PHASE 2: 海马体检索 + 小脑固化  │');
  console.log('└──────────────────────────────────┘');

  // ④ 保存小脑方案
  console.log('\n📋 2a. 保存一个"写诗"小脑方案');
  const planRes = await post(`${BASE}/api/agent/${AGENT}/memory/cerebellum`, {
    scenario: '写古典诗词',
    keywords: ['写诗', '诗词', '古诗', '五言', '七言', '绝句', '律诗', '创作'],
    conditions: ['需要创作古典诗词', '需要遵循格律'],
    steps: [
      '确认体裁(五绝/七绝/五律/七律/词)',
      '确认主题和意境',
      '起句: 点明时间/地点/情境',
      '承句: 展开描写,丰富画面',
      '转句: 转折或深化情感',
      '合句: 收束全诗,升华主题',
    ],
    notes: ['五绝20字,七绝28字', '注意平仄和押韵', '避免直白说理,用意象表达'],
    confidence: 0.9,
  });
  test(planRes.success, `④ 小脑方案保存成功`);

  // 验证 hippocampus 索引
  const hippIdx = readFile(path.join(MEM, 'hippocampus-index.json'));
  test(hippIdx.includes('写古典诗词'), `hippocampus-index.json 包含新条目`);

  // ⑤ 发送匹配任务 → 验证海马体命中
  console.log('\n📋 2b. 发送"写诗"任务 → 验证海马体检索');
  const r3 = await post(`${BASE}/api/agent/task/dispatch`, {
    from: 'master', to: AGENT,
    task: '请写一首七言绝句,主题是中秋月夜。不需要使用工具,直接创作。',
    priority: 'P2', autoExecute: true,
  });
  test(r3.success, `Dispatch 成功 (taskId: ${r3.taskId})`);
  const conv3 = r3.executionConvId;

  console.log('  ⏳ 等待对话完成...');
  await waitForStatus(AGENT, conv3);
  await sleep(2000);

  // ⑥ 验证使用次数递增
  // 海马体 useCount 在 buildSystemPrompt 时更新,需等服务端写完文件
  await sleep(5000);
  const hippRaw = readFile(path.join(MEM, 'hippocampus-index.json'));
  console.log(`  🔍 hippocampus raw: ${hippRaw.substring(0, 200)}`);
  const hippIdxAfter = JSON.parse(hippRaw || '[]');
  const poetryEntry = hippIdxAfter.find(e => e.scenario === '写古典诗词');
  console.log(`  🔍 poetryEntry: ${JSON.stringify(poetryEntry)}`);
  test(poetryEntry && poetryEntry.useCount >= 1, `⑥ 海马体使用次数: ${poetryEntry?.useCount} (期望 ≥1)`);
  test(poetryEntry && poetryEntry.lastUsed !== null, `lastUsed 已更新: ${poetryEntry?.lastUsed}`);

  // ═══════════════════════════════════
  //  PHASE 3: 反思 + 合并 + 日报
  // ═══════════════════════════════════
  console.log('\n┌──────────────────────────────────┐');
  console.log('│  PHASE 3: 反思 + 合并 + 日报     │');
  console.log('└──────────────────────────────────┘');

  // ⑦ 模拟复杂对话触发反思
  console.log('\n📋 3a. 模拟复杂对话 → 验证自动反思');
  const { autoReflect } = require('../../server/services/memory-service');
  const complexMeta = {
    status: 'completed',
    stats: { toolCallCount: 8, messageCount: 4 },
    createdAt: new Date(Date.now() - 180000).toISOString(),
    updatedAt: new Date().toISOString(),
    title: '为国庆节创作一组主题诗集,包含5首诗',
  };
  const complexOutput = `创作完成!以下是创作步骤:
1. 确定国庆主题和5个子题材(国旗、山河、英雄、和平、未来)
2. 分别确定每首诗的体裁(五绝、七绝、五律、七律、词)
3. 创作第一首《旗颂》五言绝句
4. 创作第二首《山河赋》七言律诗
5. 创作第三首《英雄辞》五言律诗
6. 创作第四首《和平吟》七言绝句
7. 创作第五首《展望》词·水调歌头
8. 整体校对格律和意境

产出文件:
- workspace/poet/guoqing-collection.md
- workspace/poet/guoqing-notes.md`;

  const reflectResult = autoReflect(AGENT, 'test-complex', complexMeta, complexOutput);
  test(reflectResult !== null, `⑦ 复杂对话触发了反思`);
  test(reflectResult?.patterns?.length >= 1, `提取了 ${reflectResult?.patterns?.length} 个模式`);
  test(reflectResult?.plan !== null, `自动固化了方案`);

  // 验证 reflections 目录
  const reflectFiles = listDir(path.join(MEM, 'reflections')).filter(f => f.startsWith('reflect-'));
  test(reflectFiles.length >= 1, `reflections/ 有 ${reflectFiles.length} 个日志`);

  // ⑧ memory.md 更新
  const mem3 = readFile(path.join(MEM, 'memory.md'));
  test(!mem3.includes('已验证的模式\n(尚无记录)'), `⑧ memory.md "已验证模式" 段有内容`);

  // 模拟失败对话
  console.log('\n📋 3b. 模拟失败对话 → 验证教训提取');
  const failReflect = autoReflect(AGENT, 'test-fail', {
    status: 'failed',
    stats: { toolCallCount: 4, messageCount: 2 },
    createdAt: new Date(Date.now() - 60000).toISOString(),
    updatedAt: new Date().toISOString(),
    title: '翻译古诗为英文但格式错误',
  }, 'error: 输入格式无法解析 Error: Invalid poem format at line 3');

  test(failReflect?.lessons?.length >= 1, `失败对话提取了 ${failReflect?.lessons?.length} 个教训`);
  const mem3b = readFile(path.join(MEM, 'memory.md'));
  test(!mem3b.includes('失败教训\n(尚无记录)'), `memory.md "失败教训" 段有内容`);

  // ⑨ 记忆合并
  console.log('\n📋 3c. 记忆合并 (short-term → long-term)');
  // 创建 3 个"过期"session(8天前)
  const shortDir = path.join(MEM, 'short-term');
  const oldTs = Date.now() - 8 * 24 * 60 * 60 * 1000;
  for (let i = 0; i < 3; i++) {
    fs.writeFileSync(path.join(shortDir, `session-${oldTs + i}.md`),
      `# Session 摘要\n\n- **时间**: 2026-03-17 0${i}:00:00\n\n## 任务\n旧任务-${i}: 写一首关于秋天的诗\n\n## 结果\n秋诗完成-${i}\n`, 'utf-8');
  }

  const shortBefore = listDir(shortDir).filter(f => f.startsWith('session-'));
  console.log(`  short-term/ 合并前: ${shortBefore.length} 文件`);

  const consolidateRes = await post(`${BASE}/api/agent/${AGENT}/memory/consolidate`, {});
  test(consolidateRes.merged === 3, `⑨ 合并了 ${consolidateRes.merged} 个过期 session (期望 3)`);

  const ltFiles = listDir(path.join(MEM, 'long-term'));
  test(ltFiles.some(f => f.startsWith('consolidate-')), `long-term/ 有合并记录`);

  const shortAfter = listDir(shortDir).filter(f => {
    const ts = parseInt(f.replace('session-', '').replace('.md', ''));
    return !isNaN(ts) && ts < oldTs + 5;
  });
  test(shortAfter.length === 0, `过期 session 已从 short-term/ 删除`);

  // ⑩ 日报
  console.log('\n📋 3d. 日报生成');
  const dailyRes = await post(`${BASE}/api/agent/${AGENT}/memory/daily-report`, {});
  test(dailyRes.success, `⑩ 日报生成成功`);

  const today = new Date().toISOString().substring(0, 10);
  const dailyContent = readFile(path.join(MEM, 'daily', `${today}.md`));
  test(dailyContent.includes('# 日报'), `daily/${today}.md 包含日报标题`);
  test(dailyContent.includes('对话数'), `日报包含统计数据`);
  test(dailyContent.includes('反思摘要') || dailyContent.includes('反思数'), `日报包含反思信息`);

  // ═══════════════════════════════════
  //  最终汇总
  // ═══════════════════════════════════
  console.log('\n' + '═'.repeat(56));
  console.log('\n📊 Memory 目录最终状态:');
  const finalState = {
    'short-term/': listDir(path.join(MEM, 'short-term')).filter(f => f.endsWith('.md')).length,
    'long-term/': listDir(path.join(MEM, 'long-term')).length,
    'cerebellum/': listDir(path.join(MEM, 'cerebellum')).length,
    'reflections/': listDir(path.join(MEM, 'reflections')).length,
    'daily/': listDir(path.join(MEM, 'daily')).filter(f => f.endsWith('.md')).length,
    'memory.md': `${readFile(path.join(MEM, 'memory.md')).length} 字`,
    'hippocampus': hippIdxAfter.length + ' 条索引',
  };
  for (const [k, v] of Object.entries(finalState)) {
    console.log(`  📁 ${k.padEnd(18)} ${typeof v === 'number' ? v + ' 文件' : v}`);
  }

  console.log('\n' + '═'.repeat(56));
  const status = failed === 0 ? '🎉 ALL PASSED' : '⚠️  SOME FAILED';
  console.log(`\n${status}: ${passed}/${total} 通过, ${failed} 失败\n`);

  if (failed > 0) process.exit(1);
}

main().catch(err => { console.error('💥 测试崩溃:', err); process.exit(1); });