/** * Unit Tests — agent-body-filler.js(32 号文档方案 B 的落地) * * 锁的是:撞车时等一等重试(不是当场丢弃)、重试耗尽落地为可查询的哨兵文件(不是一行 * 日志)、成功后清掉上一轮可能留下的失败标记、互斥锁只释放自己那一把(不误删别人的)、 * 以及最要紧的一条——这把锁跟 `agent-chat.js` 的 `POST /:agentId/chat` 共享同一个 Map * (用真实 `agent-session-lock.js` 模块,不 mock 它,直接验证两边操作的是同一个对象)。 * * 用 vi.useFakeTimers() 控制重试之间的等待——RETRY_DELAY_MS 是 5s/15s 量级,真等出来 * 这个测试文件就要跑 20+ 秒。 * * locator.bodyDir() 没有 env 覆盖点,跟 identity-context.test.js / * describe-role-definition-truncation.test.js 同款手法:起一个 zz- 前缀的真实临时本体 * 目录,afterEach 清理不留痕迹。 */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'fs'; import path from 'path'; const REPO_ROOT = path.resolve(__dirname, '../../..'); const AGENTS_ROOT = path.join(REPO_ROOT, 'system', 'agents'); describe('agent-body-filler.fillAgentBodyReliably', () => { const targetAgentId = 'zz-test-body-filler-target'; const bodyDir = path.join(AGENTS_ROOT, targetAgentId); const markerPath = path.join(bodyDir, '.body_fill_failed.json'); let fillAgentBodyReliably; let agentChatLocks; beforeEach(() => { fs.mkdirSync(bodyDir, { recursive: true }); vi.useFakeTimers(); delete require.cache[require.resolve('../../src/core/agent-session-lock.js')]; delete require.cache[require.resolve('../../services/agent-body-filler.js')]; ({ agentChatLocks } = require('../../src/core/agent-session-lock.js')); ({ fillAgentBodyReliably } = require('../../services/agent-body-filler.js')); agentChatLocks.clear(); }); afterEach(() => { vi.useRealTimers(); agentChatLocks.clear(); fs.rmSync(bodyDir, { recursive: true, force: true }); }); /** 跑 fillAgentBodyReliably 的同时把假定时器一路推走,让内部的 sleep() 立刻"过去" */ async function runWithTimers(promise) { let settled = false; promise.finally(() => { settled = true; }); for (let i = 0; i < 10 && !settled; i++) { await vi.advanceTimersByTimeAsync(20_000); } return promise; } it('第一次就成功:attempts=1,不写失败标记', async () => { const agentManager = { callAgent: vi.fn().mockResolvedValue({ output: 'ok' }) }; const result = await runWithTimers(fillAgentBodyReliably({ agentManager, targetAgentId, message: 'brief', })); expect(result).toEqual({ ok: true, attempts: 1 }); expect(agentManager.callAgent).toHaveBeenCalledTimes(1); expect(agentManager.callAgent).toHaveBeenCalledWith('agent-creator', 'brief', expect.any(Object)); expect(fs.existsSync(markerPath)).toBe(false); // 锁用完必须释放,不能一直占着 expect(agentChatLocks.has('agent-creator')).toBe(false); }); it('第一次调用抛错、第二次成功:attempts=2,不写失败标记', async () => { const agentManager = { callAgent: vi.fn() .mockRejectedValueOnce(new Error('transient')) .mockResolvedValueOnce({ output: 'ok' }), }; const result = await runWithTimers(fillAgentBodyReliably({ agentManager, targetAgentId, message: 'brief', })); expect(result).toEqual({ ok: true, attempts: 2 }); expect(agentManager.callAgent).toHaveBeenCalledTimes(2); expect(fs.existsSync(markerPath)).toBe(false); }); it('agent-creator 被占用时不当场放弃,等锁释放后重试成功', async () => { // 模拟"agent-creator 正忙":先手工占上锁,跟 agent-chat.js 的 POST /:agentId/chat // 会做的事一模一样——这就是两边共享同一个 Map 才成立的场景。 agentChatLocks.set('agent-creator', { convId: 'someone-elses-chat', startedAt: Date.now() }); const agentManager = { callAgent: vi.fn().mockResolvedValue({ output: 'ok' }) }; const resultPromise = fillAgentBodyReliably({ agentManager, targetAgentId, message: 'brief' }); // 第一次尝试应该看到锁被占用,完全不调用 agentManager await vi.advanceTimersByTimeAsync(1); expect(agentManager.callAgent).not.toHaveBeenCalled(); // 锁释放(比如占用它的那次 chat 结束了),推进定时器让重试发生 agentChatLocks.delete('agent-creator'); const result = await runWithTimers(resultPromise); expect(result.ok).toBe(true); expect(agentManager.callAgent).toHaveBeenCalledTimes(1); }); it('重试耗尽:ok:false,写出可查询的失败标记,内容带上尝试次数与最后一次错误', async () => { const agentManager = { callAgent: vi.fn().mockRejectedValue(new Error('agent-creator 挂了')) }; const result = await runWithTimers(fillAgentBodyReliably({ agentManager, targetAgentId, message: 'brief', })); expect(result.ok).toBe(false); expect(result.attempts).toBe(3); // MAX_ATTEMPTS expect(agentManager.callAgent).toHaveBeenCalledTimes(3); expect(fs.existsSync(markerPath)).toBe(true); const marker = JSON.parse(fs.readFileSync(markerPath, 'utf-8')); expect(marker.targetAgentId).toBe(targetAgentId); expect(marker.attempts).toBe(3); expect(marker.lastError).toContain('agent-creator 挂了'); expect(marker.failedAt).toBeTruthy(); // 锁不能因为失败就永久占着 expect(agentChatLocks.has('agent-creator')).toBe(false); }); it('上一轮留下失败标记后,这一轮成功了要清掉——不能让哨兵文件继续骗人', async () => { fs.writeFileSync(markerPath, JSON.stringify({ stale: true })); const agentManager = { callAgent: vi.fn().mockResolvedValue({ output: 'ok' }) }; const result = await runWithTimers(fillAgentBodyReliably({ agentManager, targetAgentId, message: 'brief', })); expect(result.ok).toBe(true); expect(fs.existsSync(markerPath)).toBe(false); }); it('只释放自己抢到的那把锁:调用期间锁被第三方抢先改写不会被这里误删', async () => { // agentManager.callAgent 在途期间,模拟第三方(比如超时安全阀强制释放后又被 // 另一次调用抢到)把锁换成了别的 convId——finally 里的判据必须是 // "convId 匹配才删",不能是"只要有锁就删"。 let resolveCall; const agentManager = { callAgent: vi.fn(() => new Promise((r) => { resolveCall = r; })), }; const resultPromise = fillAgentBodyReliably({ agentManager, targetAgentId, message: 'brief' }); await vi.advanceTimersByTimeAsync(1); const mine = agentChatLocks.get('agent-creator'); expect(mine).toBeTruthy(); // 第三方悄悄把锁换成了自己的 agentChatLocks.set('agent-creator', { convId: 'third-party-conv', startedAt: Date.now() }); resolveCall({ output: 'ok' }); await runWithTimers(resultPromise); // 第三方的锁必须还在——不能被这次调用的 finally 顺手清掉 expect(agentChatLocks.get('agent-creator')).toEqual( expect.objectContaining({ convId: 'third-party-conv' }) ); }); });