agent-body-filler.test.js
7.13 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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
/**
* 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' })
);
});
});