/** * 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//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); }); }); });