agent-body-filler.test.js 7.13 KB
/**
 * 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' })
    );
  });
});