agent-chat-interrupt.test.js
4.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
/**
* 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;
});
});