agent-chat-interrupt.test.js 4.89 KB
/**
 * 40 号文档方案二的两条后端回归:
 *
 * 1. 风险①:同一 agentId + 同一 convId 的并发请求过去完全没有闸门(`agentChatLocks` 的判断
 *    只挡"不同 convId"),一直靠前端 textarea 的 disabled 属性挡住。"打断并发送"要解锁输入框,
 *    这条路径就变得可达——两个循环同时喂同一个 ACP 会话是未定义行为。
 * 2. `stop` 端点现在要等锁真正释放(或 3 秒预算到期)才返回,`cleaned` 字段供前端判断
 *    能不能安全发第二条。
 */
process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
process.env.AGENT_SESSION_IDLE_MS = '60000';

import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import express from 'express';
import { createRequire } from 'node:module';

const require_ = createRequire(import.meta.url);
const ROOT = path.resolve(__dirname, '../../..');
const AGENT = 'zz-chat-interrupt';
const BODY = path.join(ROOT, 'system', 'agents', AGENT);

function clearRuntimeState() {
  for (const d of ['conversations', '_global', 'memory']) {
    fs.rmSync(path.join(BODY, d), { recursive: true, force: true });
  }
}

describe('agent-chat · 打断-排队(40 号文档)', () => {
  let base, srv;

  beforeAll(async () => {
    clearRuntimeState();
    fs.mkdirSync(BODY, { recursive: true });
    fs.writeFileSync(path.join(BODY, 'config.json'), JSON.stringify({ id: AGENT, type: 'skill', name: AGENT, status: 'active' }, null, 2));
    fs.writeFileSync(path.join(BODY, 'identity.md'), `# ${AGENT}\n`);
    fs.writeFileSync(path.join(BODY, 'agent.md'), '# 打断-排队单测用的常驻本体\n');

    const app = express();
    app.use(express.json());
    app.use('/api/agent', require_('../../routes/agent-chat'));
    await new Promise((r) => { srv = app.listen(0, '127.0.0.1', () => { base = `http://127.0.0.1:${srv.address().port}`; r(); }); });
  });

  afterAll(async () => {
    if (srv) await new Promise((r) => srv.close(r));
    clearRuntimeState();
  });

  afterEach(() => vi.restoreAllMocks());

  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  it('同一 convId 的第二个请求在第一个还没跑完时会被 429 拒绝,不会两个循环同时跑', async () => {
    const conv = 'race-same-conv';
    const { ClaudeCliProtocol } = require_('@mas/acp-core');
    const original = ClaudeCliProtocol.prototype.sendPrompt;
    // 拖住第一个请求,制造一个真实的并发窗口
    vi.spyOn(ClaudeCliProtocol.prototype, 'sendPrompt').mockImplementation(function (...args) {
      return sleep(300).then(() => original.apply(this, args));
    });

    const first = fetch(`${base}/api/agent/${AGENT}/chat`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: 'first', conversationId: conv }),
    });
    await sleep(50); // 确保 first 已经进了 handler、拿到了锁
    const second = await fetch(`${base}/api/agent/${AGENT}/chat`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: 'second', conversationId: conv }),
    });

    expect(second.status).toBe(429);
    const body = await second.json();
    expect(body.sameConversation).toBe(true);

    const firstRes = await first;
    await firstRes.text(); // 读完流,等 finally 跑完
  });

  it('stop 之后:锁在预算内释放,返回 cleaned:true', async () => {
    const conv = 'stop-cleaned';
    const chatDone = fetch(`${base}/api/agent/${AGENT}/chat`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: 'ping', conversationId: conv }),
    }).then(r => r.text());

    await sleep(50); // 等它先跑起来、拿到锁
    const stopRes = await fetch(`${base}/api/agent/${AGENT}/chat/${conv}/stop`, { method: 'POST' });
    const stopBody = await stopRes.json();
    expect(stopBody.success).toBe(true);
    expect(stopBody.cleaned).toBe(true);

    await chatDone;
  });

  it('stop 之后立刻发同一个 convId 的新消息不会被 429 拒绝(cleaned 确认过锁已释放)', async () => {
    const conv = 'stop-then-resend';
    const chatDone = fetch(`${base}/api/agent/${AGENT}/chat`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: 'ping', conversationId: conv }),
    }).then(r => r.text());

    await sleep(50);
    const stopRes = await fetch(`${base}/api/agent/${AGENT}/chat/${conv}/stop`, { method: 'POST' });
    const { cleaned } = await stopRes.json();
    expect(cleaned).toBe(true);

    const resendRes = await fetch(`${base}/api/agent/${AGENT}/chat`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: 'resend', conversationId: conv }),
    });
    expect(resendRes.status).toBe(200);
    await resendRes.text();
    await chatDone;
  });
});