persistence-tools.test.js 5.79 KB
/**
 * persistence-tools 单测
 *
 * 覆盖 5 个工具:
 *   write_todolist / update_todo_item / update_runtime_state /
 *   timeline_append / record_memory
 */

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

const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-pt-'));
process.env.HOME = TEST_HOME;

const pathResolver = require('../../../services/devloop/path-resolver');
const tools = require('../../../src/tools/devloop/persistence-tools');

function setupProject() {
  pathResolver._resetForTesting();
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-proj-'));
  const pid = 'p-test01';
  // 注入 cache (绕过 register)
  pathResolver.init();
  // hack: 直接写 project.json + 让 init 重扫不可行,改为构造 linked 注册表
  const reg = {
    [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' },
  };
  fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true });
  fs.writeFileSync(
    path.join(TEST_HOME, '.devloop', 'linked-projects.json'),
    JSON.stringify(reg)
  );
  pathResolver._resetForTesting();
  return { pid, root, company_id: 'c-test' };
}

describe('persistence-tools', () => {
  let ctx;
  let env;

  beforeEach(() => {
    env = setupProject();
    ctx = {
      pid: env.pid,
      agent: 'pm',
      call_id: 'call-1',
      company_id: env.company_id,
    };
  });

  afterEach(() => {
    fs.rmSync(env.root, { recursive: true, force: true });
  });

  describe('write_todolist', () => {
    it('writes initial todolist with first item doing', async () => {
      const r = await tools.write_todolist.execute({
        items: [
          { id: 'step-001', description: 'first step' },
          { id: 'step-002', description: 'second step' },
        ],
      }, ctx);
      expect(r.ok).toBe(true);
      expect(r.total).toBe(2);
      const written = JSON.parse(fs.readFileSync(r.path, 'utf8'));
      expect(written.items[0].status).toBe('doing');
      expect(written.items[1].status).toBe('todo');
      expect(written.current).toBe('step-001');
      expect(written.progress_pct).toBe(0);
    });

    it('requires call_id in ctx', async () => {
      await expect(tools.write_todolist.execute({
        items: [{ id: 'step-001', description: 'x' }],
      }, { pid: env.pid, agent: 'pm' })).rejects.toThrow(/call_id/);
    });

    it('emits SSE when broker present', async () => {
      const emitted = [];
      ctx.sseBroker = { emit: (pid, kind, p) => emitted.push({ pid, kind, p }) };
      await tools.write_todolist.execute({
        items: [{ id: 'step-001', description: 'x' }],
      }, ctx);
      expect(emitted).toHaveLength(1);
      expect(emitted[0].kind).toBe('agent.todolist');
    });
  });

  describe('update_todo_item', () => {
    beforeEach(async () => {
      await tools.write_todolist.execute({
        items: [
          { id: 'step-001', description: 'a' },
          { id: 'step-002', description: 'b' },
        ],
      }, ctx);
    });

    it('transitions doing → done and advances current to next', async () => {
      const r = await tools.update_todo_item.execute({
        id: 'step-001', status: 'done', output_ref: 'units.json',
      }, ctx);
      expect(r.ok).toBe(true);
      expect(r.progress_pct).toBe(50);
      const p = pathResolver.resolveAgentTodolist(env.pid, 'pm');
      const tl = JSON.parse(fs.readFileSync(p, 'utf8'));
      expect(tl.items[0].status).toBe('done');
      expect(tl.items[0].output_ref).toBe('units.json');
      expect(tl.items[1].status).toBe('doing');
      expect(tl.current).toBe('step-002');
    });

    it('rejects unknown step id', async () => {
      await expect(tools.update_todo_item.execute({
        id: 'step-999', status: 'done',
      }, ctx)).rejects.toThrow(/unknown step/);
    });
  });

  describe('timeline_append', () => {
    it('appends an event to timeline.ndjson', async () => {
      const r = await tools.timeline_append.execute({
        kind: 'doc.updated',
        summary: 'PRD scope rev 2',
        ref: 'prd/scope.html',
      }, ctx);
      expect(r.ok).toBe(true);
      expect(r.ts).toBeTruthy();
      const p = pathResolver.resolveTimelineLog(env.pid);
      const content = fs.readFileSync(p, 'utf8').trim().split('\n');
      const lastEvent = JSON.parse(content[content.length - 1]);
      expect(lastEvent.kind).toBe('doc.updated');
      expect(lastEvent.source).toBe('pm');
    });
  });

  describe('record_memory', () => {
    let uniqueAgent;
    beforeEach(() => {
      // memory dir is under DATA_DIR/companies/<cid>/memory — not isolated by env.root.
      // Use a unique agent per test to avoid cross-test pollution; cleanup after.
      uniqueAgent = 'test-mem-' + Math.random().toString(36).slice(2, 8);
      ctx.agent = uniqueAgent;
    });
    afterEach(() => {
      const dir = pathResolver.resolveMemoryDir(env.company_id, uniqueAgent);
      fs.rmSync(dir, { recursive: true, force: true });
    });

    it('writes memory .md with frontmatter (new)', async () => {
      const r = await tools.record_memory.execute({
        topic: 'jwt-auth-points',
        content: 'use jose lib with ES256 signing alg',
        relevance: 0.9,
      }, ctx);
      expect(r.ok).toBe(true);
      expect(r.upserted).toBe(false);
      expect(r.hits).toBe(1);
      const content = fs.readFileSync(r.path, 'utf8');
      expect(content).toMatch(/relevance_score: 0\.9/);
      expect(content).toMatch(/jose lib/);
    });

    it('upserts on same topic with hits++', async () => {
      await tools.record_memory.execute({
        topic: 'jwt-auth', content: 'first version content here', relevance: 0.5,
      }, ctx);
      const r2 = await tools.record_memory.execute({
        topic: 'jwt-auth', content: 'updated version content here', relevance: 0.7,
      }, ctx);
      expect(r2.upserted).toBe(true);
      expect(r2.hits).toBe(2);
    });
  });
});