identity-context.test.js 7.42 KB
/**
 * Unit Tests — identity-context.js(本体装配静默坑修复)
 *
 * 锁的是「work 节点只有 agent.md,soul/identity/tools/heartbeat/OKR/hippocampus/skills
 * 全部缺失」这条修复的具体行为:读了哪些文件、按什么条件跳过、不重复已经被
 * PromptBuilder.buildCompanyStateParts 读过的那三样(user.md/memory.md/近期记忆)。
 *
 * OkrService / retrieveRelevantPlans 两个依赖没有走 vi.mock——本仓库服务层是纯
 * CommonJS(require/module.exports),vi.mock 面向的是 Vite 处理过的 ESM 模块图,
 * 对 identity-context.js 内部自己 require() 出来的这条链路不生效(实测:mock 完全不拦截,
 * `okrMod.OkrService` 收到的还是真实的类,不是 vi.fn())。改用更直接的手法——CJS 的
 * `require()` 在同一进程内对同一路径返回**同一个** `module.exports` 对象,所以直接在
 * `beforeEach` 里把这两个依赖模块的导出属性**原地换成假实现**,`identity-context.js`
 * 用 `require()` 拿到的就是换过的那份;`afterEach` 换回来,不留痕迹。
 * 这两个依赖也没有任何 env 覆盖点能让它们落到隔离的临时目录(不像 GRAPH_DB_PATH 那套),
 * 真去读会既不确定又污染仓库数据,所以只验证「调用了、传参对不对、返回值有没有被
 * 正确拼进去」,不验证它们内部的真实检索逻辑(那是各自模块自己的测试该管的事)。
 */
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

const okrModule = require('../../services/okr-service');
const memoryModule = require('../../services/memory-service');
const originalOkrService = okrModule.OkrService;
const originalRetrieveRelevantPlans = memoryModule.retrieveRelevantPlans;

describe('identity-context.buildIdentityContextParts', () => {
  let bodyDir;
  let buildIdentityContextParts;
  let okrCalls;
  let hippocampusCalls;

  beforeEach(() => {
    bodyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'identity-ctx-body-'));

    okrCalls = [];
    hippocampusCalls = [];
    // 原地换成假实现:identity-context.js 的 require('../../services/okr-service')
    // 拿到的是同一个 module.exports 对象,属性换了它就用的是假的。
    okrModule.OkrService = function FakeOkrService() {
      return {
        getActiveContextForAgent(agentId) {
          okrCalls.push(agentId);
          return agentId === 'has-goal' ? '## 🎯 当前参与的目标\n\n模拟目标文本' : '';
        },
      };
    };
    memoryModule.retrieveRelevantPlans = (agentId, searchText) => {
      hippocampusCalls.push([agentId, searchText]);
      return agentId === 'has-goal' ? `模拟检索结果(搜索词:${searchText})` : '';
    };

    delete require.cache[require.resolve('../../src/core/identity-context.js')];
    ({ buildIdentityContextParts } = require('../../src/core/identity-context.js'));
  });

  afterEach(() => {
    okrModule.OkrService = originalOkrService;
    memoryModule.retrieveRelevantPlans = originalRetrieveRelevantPlans;
    delete require.cache[require.resolve('../../src/core/identity-context.js')];
    fs.rmSync(bodyDir, { recursive: true, force: true });
  });

  function write(rel, content) {
    const fp = path.join(bodyDir, rel);
    fs.mkdirSync(path.dirname(fp), { recursive: true });
    fs.writeFileSync(fp, content);
  }

  it('bodyDir 为空时返回空数组,不抛错', () => {
    expect(buildIdentityContextParts({ bodyDir: null })).toEqual([]);
  });

  it('soul.md/identity.md/tools.md 都写了时按顺序读出,缺的那个跳过', () => {
    write('soul.md', 'MY SOUL');
    write('tools.md', 'MY TOOLS'); // identity.md 故意不写
    const parts = buildIdentityContextParts({ bodyDir });
    expect(parts).toContain('MY SOUL');
    expect(parts).toContain('MY TOOLS');
    expect(parts.join('\n')).not.toContain('identity.md');
  });

  it('heartbeat.md 存在时读入,不存在时跳过(不报错)', () => {
    expect(buildIdentityContextParts({ bodyDir })).not.toContain(expect.stringContaining('HEARTBEAT'));
    write('heartbeat.md', 'HEARTBEAT CONTENT');
    expect(buildIdentityContextParts({ bodyDir })).toContain('HEARTBEAT CONTENT');
  });

  it('没有 agentId 时不查 OKR / hippocampus(不是「查了但是空」,是根本不查)', () => {
    buildIdentityContextParts({ bodyDir });
    expect(okrCalls).toEqual([]);
    expect(hippocampusCalls).toEqual([]);
  });

  it('有 agentId 时查 OKR 与 hippocampus,结果非空才拼进去', () => {
    const parts = buildIdentityContextParts({ bodyDir, agentId: 'has-goal', searchText: '写一份 PRD' });
    expect(parts.some((p) => p.includes('模拟目标文本'))).toBe(true);
    expect(parts.some((p) => p.includes('模拟检索结果'))).toBe(true);
    expect(hippocampusCalls).toContainEqual(['has-goal', '写一份 PRD']);

    // agentId 存在但两个依赖都返回空字符串——不拼空段落
    const empty = buildIdentityContextParts({ bodyDir, agentId: 'no-goal', searchText: 'x' });
    expect(empty.some((p) => p.includes('目标') || p.includes('检索'))).toBe(false);
  });

  it('OKR 查询抛错时不影响其它内容,只是那一段缺失(不整体失败)', () => {
    okrModule.OkrService = function ThrowingOkrService() {
      return { getActiveContextForAgent: () => { throw new Error('boom'); } };
    };
    delete require.cache[require.resolve('../../src/core/identity-context.js')];
    ({ buildIdentityContextParts } = require('../../src/core/identity-context.js'));

    write('soul.md', 'STILL HERE');
    const parts = buildIdentityContextParts({ bodyDir, agentId: 'whoever' });
    expect(parts).toContain('STILL HERE');
  });

  it('main_skill/SKILL.md 存在时带专属标题拼入', () => {
    write('.claude/skills/main_skill/SKILL.md', 'MAIN SKILL BODY');
    const parts = buildIdentityContextParts({ bodyDir });
    expect(parts.some((p) => p.includes('我的核心主技能') && p.includes('MAIN SKILL BODY'))).toBe(true);
  });

  it('workspace/_lessons.md 存在时带警示标题拼入', () => {
    write('workspace/_lessons.md', '别再犯这个错');
    const parts = buildIdentityContextParts({ bodyDir });
    expect(parts.some((p) => p.includes('本地试错防坑经验') && p.includes('别再犯这个错'))).toBe(true);
  });

  it('skills.md 与其它 skill 子目录(排除 main_skill)都被扫到', () => {
    write('.claude/skills/skills.md', 'SKILLS INDEX');
    write('.claude/skills/main_skill/SKILL.md', 'MAIN'); // 不应该被当成「依赖技能」再扫一次
    write('.claude/skills/foo-skill/SKILL.md', 'FOO SKILL BODY');
    write('.claude/skills/bar-skill/SKILL.md', 'BAR SKILL BODY');
    const parts = buildIdentityContextParts({ bodyDir });
    expect(parts).toContain('SKILLS INDEX');
    expect(parts.some((p) => p.includes('依赖技能: foo-skill') && p.includes('FOO SKILL BODY'))).toBe(true);
    expect(parts.some((p) => p.includes('依赖技能: bar-skill') && p.includes('BAR SKILL BODY'))).toBe(true);
    // main_skill 不能又被当成一个「依赖技能」重复拼一次
    expect(parts.filter((p) => p.includes('MAIN')).length).toBe(1);
  });

  it('.claude/skills 目录本身不存在时不报错,其它内容照常返回', () => {
    write('soul.md', 'OK');
    expect(() => buildIdentityContextParts({ bodyDir })).not.toThrow();
    expect(buildIdentityContextParts({ bodyDir })).toContain('OK');
  });
});