elsabro 6.0.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/elsabro-orchestrator.md +39 -0
- package/bin/install.js +8 -4
- package/commands/elsabro/add-phase.md +20 -0
- package/commands/elsabro/complete-milestone.md +20 -0
- package/commands/elsabro/design-ui.md +21 -0
- package/commands/elsabro/execute.md +132 -12
- package/commands/elsabro/new-milestone.md +23 -0
- package/commands/elsabro/party.md +80 -23
- package/commands/elsabro/quick.md +19 -2
- package/flow-engine/src/agent-cards.json +74 -0
- package/flow-engine/src/callbacks.js +268 -0
- package/flow-engine/src/cli.js +597 -0
- package/flow-engine/src/executors.js +14 -1
- package/flow-engine/src/index.js +4 -2
- package/flow-engine/src/party.js +414 -0
- package/flow-engine/src/runner.js +5 -5
- package/flow-engine/tests/callbacks.test.js +274 -0
- package/flow-engine/tests/cli.test.js +208 -0
- package/flow-engine/tests/executors-complex.test.js +61 -1
- package/flow-engine/tests/integration.test.js +667 -38
- package/flow-engine/tests/party.test.js +724 -0
- package/flows/development-flow.json +104 -120
- package/package.json +5 -3
- package/references/next-step-engine.md +19 -0
- package/references/state-sync.md +15 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it, before, after } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
|
|
9
|
+
const { PartyEngine, RELEVANCE_MAP, slugify, detectAgentNames } = require('../src/party');
|
|
10
|
+
|
|
11
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function tmpDir() {
|
|
14
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'party-test-'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function mockOnAgentTurn({ agent, round }) {
|
|
18
|
+
return `${agent.name} says something in round ${round}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mockOnSynthesize({ history, topic, agents }) {
|
|
22
|
+
return {
|
|
23
|
+
consensus: ['We agree on X'],
|
|
24
|
+
debates: ['We disagree on Y'],
|
|
25
|
+
actionItems: ['Do Z'],
|
|
26
|
+
keyInsights: ['Insight A'],
|
|
27
|
+
suggestedNext: 'execute'
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Suite 1: selectAgents ────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
describe('selectAgents', () => {
|
|
34
|
+
const engine = new PartyEngine();
|
|
35
|
+
|
|
36
|
+
it('selects top 3 for architecture topic', () => {
|
|
37
|
+
const agents = engine.selectAgents('architecture');
|
|
38
|
+
assert.equal(agents.length, 3);
|
|
39
|
+
// planner(3) > executor(2) > debugger(1)
|
|
40
|
+
assert.equal(agents[0].id, 'planner');
|
|
41
|
+
assert.equal(agents[1].id, 'executor');
|
|
42
|
+
assert.equal(agents[2].id, 'debugger');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('selects top 3 for testing topic', () => {
|
|
46
|
+
const agents = engine.selectAgents('testing');
|
|
47
|
+
assert.equal(agents.length, 3);
|
|
48
|
+
// qa(3) > executor(2) > verifier(1)
|
|
49
|
+
assert.equal(agents[0].id, 'qa');
|
|
50
|
+
assert.equal(agents[1].id, 'executor');
|
|
51
|
+
assert.equal(agents[2].id, 'verifier');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('handles multi-keyword scoring', () => {
|
|
55
|
+
const agents = engine.selectAgents('design requirements');
|
|
56
|
+
// design: ux-designer(3), planner(2), analyst(1)
|
|
57
|
+
// requirements: analyst(3), planner(2), ux-designer(1)
|
|
58
|
+
// Combined: ux-designer(4), planner(4), analyst(4) — tie broken by first-match position
|
|
59
|
+
// design matches ux-designer first (position 0), requirements matches analyst first (position 1)
|
|
60
|
+
// ux-designer first-match = 0, planner first-match = 0, analyst first-match = 0
|
|
61
|
+
assert.equal(agents.length, 3);
|
|
62
|
+
// All three should be present (order may vary due to tie-breaking)
|
|
63
|
+
const ids = agents.map(a => a.id);
|
|
64
|
+
assert.ok(ids.includes('ux-designer'));
|
|
65
|
+
assert.ok(ids.includes('planner'));
|
|
66
|
+
assert.ok(ids.includes('analyst'));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('breaks ties by first keyword appearance', () => {
|
|
70
|
+
// Both "scope" and "planning" give scrum-master points
|
|
71
|
+
// scope: analyst(3), scrum-master(2), planner(1)
|
|
72
|
+
// planning: planner(3), scrum-master(2), analyst(1)
|
|
73
|
+
// analyst: 3+1=4, scrum-master: 2+2=4, planner: 1+3=4
|
|
74
|
+
// All tied at 4 — first-match for analyst at "scope"(pos 0), scrum-master at "scope"(pos 0), planner at "scope"(pos 0)
|
|
75
|
+
const agents = engine.selectAgents('scope planning');
|
|
76
|
+
assert.equal(agents.length, 3);
|
|
77
|
+
const ids = agents.map(a => a.id);
|
|
78
|
+
assert.ok(ids.includes('analyst'));
|
|
79
|
+
assert.ok(ids.includes('scrum-master'));
|
|
80
|
+
assert.ok(ids.includes('planner'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('uses defaults when no keywords match', () => {
|
|
84
|
+
const agents = engine.selectAgents('something unrelated');
|
|
85
|
+
assert.equal(agents.length, 3);
|
|
86
|
+
// Default scores: analyst(2), planner(2), executor(1) — analyst/planner tied, both at 2
|
|
87
|
+
const ids = agents.map(a => a.id);
|
|
88
|
+
assert.ok(ids.includes('analyst'));
|
|
89
|
+
assert.ok(ids.includes('planner'));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('respects manual --agents override', () => {
|
|
93
|
+
const agents = engine.selectAgents('anything', { agents: ['debugger', 'qa'] });
|
|
94
|
+
assert.equal(agents.length, 2);
|
|
95
|
+
assert.equal(agents[0].id, 'debugger');
|
|
96
|
+
assert.equal(agents[1].id, 'qa');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('limits to maxAgents parameter', () => {
|
|
100
|
+
const agents = engine.selectAgents('architecture', { maxAgents: 2 });
|
|
101
|
+
assert.equal(agents.length, 2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('detects agent name in topic text', () => {
|
|
105
|
+
const agents = engine.selectAgents('ask Sherlock about the bug');
|
|
106
|
+
// Sherlock = debugger, forced first
|
|
107
|
+
assert.equal(agents[0].id, 'debugger');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('handles empty topic', () => {
|
|
111
|
+
const agents = engine.selectAgents('');
|
|
112
|
+
assert.equal(agents.length, 3);
|
|
113
|
+
// Should get defaults
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('handles null/undefined topic gracefully', () => {
|
|
117
|
+
const agents = engine.selectAgents(null);
|
|
118
|
+
assert.equal(agents.length, 3);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('does not duplicate for repeated keywords', () => {
|
|
122
|
+
// Repeating "testing" should not double-score agents
|
|
123
|
+
const agents1 = engine.selectAgents('testing');
|
|
124
|
+
const agents2 = engine.selectAgents('testing testing testing');
|
|
125
|
+
// Same result — dedup prevents re-scoring
|
|
126
|
+
assert.deepEqual(
|
|
127
|
+
agents1.map(a => a.id),
|
|
128
|
+
agents2.map(a => a.id)
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('all 9 agents are reachable via some keyword', () => {
|
|
133
|
+
const allAgentIds = Object.keys(engine.getCards());
|
|
134
|
+
const reachable = new Set();
|
|
135
|
+
|
|
136
|
+
// Test each keyword bucket
|
|
137
|
+
for (const keyword of Object.keys(RELEVANCE_MAP)) {
|
|
138
|
+
const agents = engine.selectAgents(keyword, { maxAgents: 9 });
|
|
139
|
+
for (const a of agents) reachable.add(a.id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const id of allAgentIds) {
|
|
143
|
+
assert.ok(reachable.has(id), `Agent ${id} should be reachable via keywords`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns correct score ordering', () => {
|
|
148
|
+
// "bug" → debugger(3), qa(2), executor(1)
|
|
149
|
+
const agents = engine.selectAgents('bug');
|
|
150
|
+
assert.equal(agents[0].id, 'debugger');
|
|
151
|
+
assert.equal(agents[1].id, 'qa');
|
|
152
|
+
assert.equal(agents[2].id, 'executor');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('instance maxAgents is respected', () => {
|
|
156
|
+
const smallEngine = new PartyEngine({ maxAgents: 2 });
|
|
157
|
+
const agents = smallEngine.selectAgents('architecture');
|
|
158
|
+
assert.equal(agents.length, 2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('manual override respects maxAgents limit', () => {
|
|
162
|
+
const agents = engine.selectAgents('x', {
|
|
163
|
+
agents: ['debugger', 'qa', 'planner', 'executor'],
|
|
164
|
+
maxAgents: 2
|
|
165
|
+
});
|
|
166
|
+
assert.equal(agents.length, 2);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── Suite 2: Agent Cards ─────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
describe('Agent Cards', () => {
|
|
173
|
+
const engine = new PartyEngine();
|
|
174
|
+
|
|
175
|
+
it('loads all 9 cards from JSON', () => {
|
|
176
|
+
const cards = engine.getCards();
|
|
177
|
+
assert.equal(Object.keys(cards).length, 9);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('each card has required fields', () => {
|
|
181
|
+
const requiredFields = ['id', 'name', 'emoji', 'style', 'focus', 'tendency'];
|
|
182
|
+
const cards = engine.getCards();
|
|
183
|
+
|
|
184
|
+
for (const [id, card] of Object.entries(cards)) {
|
|
185
|
+
for (const field of requiredFields) {
|
|
186
|
+
assert.ok(card[field] !== undefined, `Card ${id} missing field: ${field}`);
|
|
187
|
+
assert.ok(typeof card[field] === 'string', `Card ${id}.${field} should be a string`);
|
|
188
|
+
assert.ok(card[field].length > 0, `Card ${id}.${field} should not be empty`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('no duplicate names or emojis', () => {
|
|
194
|
+
const cards = Object.values(engine.getCards());
|
|
195
|
+
const names = cards.map(c => c.name);
|
|
196
|
+
const emojis = cards.map(c => c.emoji);
|
|
197
|
+
|
|
198
|
+
assert.equal(new Set(names).size, names.length, 'Duplicate names found');
|
|
199
|
+
assert.equal(new Set(emojis).size, emojis.length, 'Duplicate emojis found');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('getCard returns correct card by ID', () => {
|
|
203
|
+
const card = engine.getCard('qa');
|
|
204
|
+
assert.equal(card.name, 'Murat');
|
|
205
|
+
assert.equal(card.id, 'qa');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('getCard returns undefined for unknown ID', () => {
|
|
209
|
+
const card = engine.getCard('unknown-agent');
|
|
210
|
+
assert.equal(card, undefined);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── Suite 3: Round Management ────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
describe('Round Management', () => {
|
|
217
|
+
it('executes correct number of rounds', async () => {
|
|
218
|
+
const dir = tmpDir();
|
|
219
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 2, maxAgents: 2 });
|
|
220
|
+
|
|
221
|
+
const rounds = [];
|
|
222
|
+
const result = await engine.run('architecture testing', {}, {
|
|
223
|
+
onAgentTurn: (params) => {
|
|
224
|
+
rounds.push(params.round);
|
|
225
|
+
return mockOnAgentTurn(params);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// 2 rounds × 2 agents = 4 calls
|
|
230
|
+
assert.equal(rounds.length, 4);
|
|
231
|
+
assert.equal(result.rounds, 2);
|
|
232
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('calls onAgentTurn for each agent in each round', async () => {
|
|
236
|
+
const dir = tmpDir();
|
|
237
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 2, maxAgents: 3 });
|
|
238
|
+
|
|
239
|
+
const calls = [];
|
|
240
|
+
await engine.run('testing', {}, {
|
|
241
|
+
onAgentTurn: (params) => {
|
|
242
|
+
calls.push({ agent: params.agent.id, round: params.round });
|
|
243
|
+
return 'response';
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// 2 rounds × 3 agents = 6 calls
|
|
248
|
+
assert.equal(calls.length, 6);
|
|
249
|
+
// Round 1 should have 3 calls, round 2 should have 3 calls
|
|
250
|
+
assert.equal(calls.filter(c => c.round === 1).length, 3);
|
|
251
|
+
assert.equal(calls.filter(c => c.round === 2).length, 3);
|
|
252
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('passes cumulative history', async () => {
|
|
256
|
+
const dir = tmpDir();
|
|
257
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 2, maxAgents: 2 });
|
|
258
|
+
|
|
259
|
+
const historySizes = [];
|
|
260
|
+
await engine.run('testing', {}, {
|
|
261
|
+
onAgentTurn: (params) => {
|
|
262
|
+
historySizes.push(params.history.length);
|
|
263
|
+
return 'response';
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// R1A1: 0, R1A2: 1, R2A1: 2, R2A2: 3
|
|
268
|
+
assert.deepEqual(historySizes, [0, 1, 2, 3]);
|
|
269
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('round 1 instruction is initial perspective', async () => {
|
|
273
|
+
const dir = tmpDir();
|
|
274
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 1, maxAgents: 1 });
|
|
275
|
+
|
|
276
|
+
let instruction = '';
|
|
277
|
+
await engine.run('testing', {}, {
|
|
278
|
+
onAgentTurn: (params) => {
|
|
279
|
+
instruction = params.instruction;
|
|
280
|
+
return 'response';
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
assert.ok(instruction.includes('initial perspective'));
|
|
285
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('round N>1 instruction is react', async () => {
|
|
289
|
+
const dir = tmpDir();
|
|
290
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 2, maxAgents: 1 });
|
|
291
|
+
|
|
292
|
+
let lastInstruction = '';
|
|
293
|
+
await engine.run('testing', {}, {
|
|
294
|
+
onAgentTurn: (params) => {
|
|
295
|
+
lastInstruction = params.instruction;
|
|
296
|
+
return 'response';
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
assert.ok(lastInstruction.includes('React'));
|
|
301
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('stops early on "stop" from onRoundComplete', async () => {
|
|
305
|
+
const dir = tmpDir();
|
|
306
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 3, maxAgents: 2 });
|
|
307
|
+
|
|
308
|
+
const result = await engine.run('testing', {}, {
|
|
309
|
+
onAgentTurn: mockOnAgentTurn,
|
|
310
|
+
onRoundComplete: ({ completedRound }) => {
|
|
311
|
+
if (completedRound === 1) return 'stop';
|
|
312
|
+
return 'continue';
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
assert.equal(result.rounds, 1);
|
|
317
|
+
assert.equal(result.history.length, 2); // 1 round × 2 agents
|
|
318
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('adds round on "add_round" from onRoundComplete', async () => {
|
|
322
|
+
const dir = tmpDir();
|
|
323
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 2, maxAgents: 1 });
|
|
324
|
+
|
|
325
|
+
let addedOnce = false;
|
|
326
|
+
const result = await engine.run('testing', {}, {
|
|
327
|
+
onAgentTurn: mockOnAgentTurn,
|
|
328
|
+
onRoundComplete: ({ completedRound }) => {
|
|
329
|
+
if (completedRound === 2 && !addedOnce) {
|
|
330
|
+
addedOnce = true;
|
|
331
|
+
return 'add_round';
|
|
332
|
+
}
|
|
333
|
+
return 'continue';
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// 2 original + 1 added = 3 rounds
|
|
338
|
+
assert.equal(result.rounds, 3);
|
|
339
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('saves checkpoint after each round', async () => {
|
|
343
|
+
const dir = tmpDir();
|
|
344
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 2, maxAgents: 1 });
|
|
345
|
+
|
|
346
|
+
const result = await engine.run('testing', {}, {
|
|
347
|
+
onAgentTurn: mockOnAgentTurn
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Check checkpoints were created (may have been cleaned)
|
|
351
|
+
// The session ID is in the result
|
|
352
|
+
assert.ok(result.sessionId.startsWith('party-testing-'));
|
|
353
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('handles callback error gracefully', async () => {
|
|
357
|
+
const dir = tmpDir();
|
|
358
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 1, maxAgents: 1 });
|
|
359
|
+
|
|
360
|
+
await assert.rejects(
|
|
361
|
+
() => engine.run('testing', {}, {
|
|
362
|
+
onAgentTurn: () => { throw new Error('Agent failed'); }
|
|
363
|
+
}),
|
|
364
|
+
{ message: 'Agent failed' }
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('respects maxRounds limit', async () => {
|
|
371
|
+
const dir = tmpDir();
|
|
372
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 1, maxAgents: 1 });
|
|
373
|
+
|
|
374
|
+
const result = await engine.run('testing', {}, {
|
|
375
|
+
onAgentTurn: mockOnAgentTurn
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
assert.equal(result.rounds, 1);
|
|
379
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('auto-continues when onRoundComplete not provided', async () => {
|
|
383
|
+
const dir = tmpDir();
|
|
384
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 2, maxAgents: 1 });
|
|
385
|
+
|
|
386
|
+
const result = await engine.run('testing', {}, {
|
|
387
|
+
onAgentTurn: mockOnAgentTurn
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Should complete all rounds without onRoundComplete
|
|
391
|
+
assert.equal(result.rounds, 2);
|
|
392
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('requires onAgentTurn callback', async () => {
|
|
396
|
+
const engine = new PartyEngine();
|
|
397
|
+
await assert.rejects(
|
|
398
|
+
() => engine.run('testing', {}, {}),
|
|
399
|
+
{ message: /requires callbacks.onAgentTurn/ }
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// ── Suite 4: Synthesis ───────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
describe('Synthesis', () => {
|
|
407
|
+
it('calls onSynthesize with full history', async () => {
|
|
408
|
+
const dir = tmpDir();
|
|
409
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 1, maxAgents: 2 });
|
|
410
|
+
|
|
411
|
+
let synthArgs = null;
|
|
412
|
+
await engine.run('testing', {}, {
|
|
413
|
+
onAgentTurn: mockOnAgentTurn,
|
|
414
|
+
onSynthesize: (params) => {
|
|
415
|
+
synthArgs = params;
|
|
416
|
+
return mockOnSynthesize(params);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
assert.ok(synthArgs);
|
|
421
|
+
assert.equal(synthArgs.history.length, 2); // 1 round × 2 agents
|
|
422
|
+
assert.equal(synthArgs.topic, 'testing');
|
|
423
|
+
assert.equal(synthArgs.agents.length, 2);
|
|
424
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('returns structured result', async () => {
|
|
428
|
+
const dir = tmpDir();
|
|
429
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 1, maxAgents: 1 });
|
|
430
|
+
|
|
431
|
+
const result = await engine.run('testing', {}, {
|
|
432
|
+
onAgentTurn: mockOnAgentTurn,
|
|
433
|
+
onSynthesize: mockOnSynthesize
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
assert.ok(result.synthesis);
|
|
437
|
+
assert.ok(Array.isArray(result.synthesis.consensus));
|
|
438
|
+
assert.ok(Array.isArray(result.synthesis.debates));
|
|
439
|
+
assert.ok(Array.isArray(result.synthesis.actionItems));
|
|
440
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('handles missing onSynthesize (returns empty synthesis)', async () => {
|
|
444
|
+
const dir = tmpDir();
|
|
445
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 1, maxAgents: 1 });
|
|
446
|
+
|
|
447
|
+
const result = await engine.run('testing', {}, {
|
|
448
|
+
onAgentTurn: mockOnAgentTurn
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
assert.ok(result.synthesis);
|
|
452
|
+
assert.deepEqual(result.synthesis.consensus, []);
|
|
453
|
+
assert.deepEqual(result.synthesis.debates, []);
|
|
454
|
+
assert.deepEqual(result.synthesis.actionItems, []);
|
|
455
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('validates synthesis has keyInsights and suggestedNext', async () => {
|
|
459
|
+
const dir = tmpDir();
|
|
460
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 1, maxAgents: 1 });
|
|
461
|
+
|
|
462
|
+
const result = await engine.run('testing', {}, {
|
|
463
|
+
onAgentTurn: mockOnAgentTurn,
|
|
464
|
+
onSynthesize: mockOnSynthesize
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
assert.ok(Array.isArray(result.synthesis.keyInsights));
|
|
468
|
+
assert.equal(result.synthesis.suggestedNext, 'execute');
|
|
469
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('passes correct agents list to onSynthesize', async () => {
|
|
473
|
+
const dir = tmpDir();
|
|
474
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 1, maxAgents: 2 });
|
|
475
|
+
|
|
476
|
+
let synthAgents = [];
|
|
477
|
+
await engine.run('testing', {}, {
|
|
478
|
+
onAgentTurn: mockOnAgentTurn,
|
|
479
|
+
onSynthesize: (params) => {
|
|
480
|
+
synthAgents = params.agents;
|
|
481
|
+
return mockOnSynthesize(params);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
assert.equal(synthAgents.length, 2);
|
|
486
|
+
// For "testing": qa, executor
|
|
487
|
+
assert.ok(synthAgents.some(a => a.id === 'qa'));
|
|
488
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// ── Suite 5: Persistence ─────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
describe('Persistence', () => {
|
|
495
|
+
it('generates correct sessionId slug', () => {
|
|
496
|
+
const slug = slugify('Architecture vs Monolith!');
|
|
497
|
+
assert.equal(slug, 'architecture-vs-monolith');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('slugify handles special characters', () => {
|
|
501
|
+
assert.equal(slugify('hello world'), 'hello-world');
|
|
502
|
+
assert.equal(slugify('---start---'), 'start');
|
|
503
|
+
assert.equal(slugify(''), '');
|
|
504
|
+
assert.equal(slugify('CAPS Test'), 'caps-test');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('slugify truncates to 40 chars', () => {
|
|
508
|
+
const long = 'a'.repeat(100);
|
|
509
|
+
assert.ok(slugify(long).length <= 40);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('checkpoint files exist during run', async () => {
|
|
513
|
+
const dir = tmpDir();
|
|
514
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 2, maxAgents: 1 });
|
|
515
|
+
|
|
516
|
+
let cpCount = 0;
|
|
517
|
+
await engine.run('testing', {}, {
|
|
518
|
+
onAgentTurn: mockOnAgentTurn,
|
|
519
|
+
onRoundComplete: () => {
|
|
520
|
+
// Check checkpoint files during execution
|
|
521
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
522
|
+
cpCount = files.length;
|
|
523
|
+
return 'continue';
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// At least 1 checkpoint should have existed during run
|
|
528
|
+
assert.ok(cpCount >= 1);
|
|
529
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('checkpoint cleaned after completion (keepLast=2)', async () => {
|
|
533
|
+
const dir = tmpDir();
|
|
534
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 3, maxAgents: 1 });
|
|
535
|
+
|
|
536
|
+
const result = await engine.run('testing', {}, {
|
|
537
|
+
onAgentTurn: mockOnAgentTurn
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// 3 rounds = 3 checkpoints saved, then clean(keepLast=2) → 2 remain
|
|
541
|
+
const files = fs.readdirSync(dir).filter(f => f.startsWith('party-'));
|
|
542
|
+
assert.ok(files.length <= 2, `Expected <=2 checkpoints, got ${files.length}`);
|
|
543
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('result includes valid sessionId', async () => {
|
|
547
|
+
const dir = tmpDir();
|
|
548
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 1, maxAgents: 1 });
|
|
549
|
+
|
|
550
|
+
const result = await engine.run('my topic here', {}, {
|
|
551
|
+
onAgentTurn: mockOnAgentTurn
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
assert.ok(result.sessionId.startsWith('party-my-topic-here-'));
|
|
555
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('result includes complete history entries', async () => {
|
|
559
|
+
const dir = tmpDir();
|
|
560
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 1, maxAgents: 2 });
|
|
561
|
+
|
|
562
|
+
const result = await engine.run('testing', {}, {
|
|
563
|
+
onAgentTurn: mockOnAgentTurn
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
assert.equal(result.history.length, 2);
|
|
567
|
+
for (const entry of result.history) {
|
|
568
|
+
assert.ok(entry.round);
|
|
569
|
+
assert.ok(entry.agent);
|
|
570
|
+
assert.ok(entry.agent.id);
|
|
571
|
+
assert.ok(entry.agent.name);
|
|
572
|
+
assert.ok(typeof entry.response === 'string');
|
|
573
|
+
assert.ok(entry.timestamp);
|
|
574
|
+
}
|
|
575
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('result agents list matches selected agents', async () => {
|
|
579
|
+
const dir = tmpDir();
|
|
580
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 1, maxAgents: 2 });
|
|
581
|
+
|
|
582
|
+
const result = await engine.run('bug debug', {}, {
|
|
583
|
+
onAgentTurn: mockOnAgentTurn
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
assert.equal(result.agents.length, 2);
|
|
587
|
+
// "bug": debugger(3), qa(2), executor(1) — top 2
|
|
588
|
+
assert.equal(result.agents[0].id, 'debugger');
|
|
589
|
+
assert.equal(result.agents[1].id, 'qa');
|
|
590
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// ── Suite 6: Integration ─────────────────────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
describe('Integration', () => {
|
|
597
|
+
it('full party session with mock callbacks (3 rounds, 3 agents)', async () => {
|
|
598
|
+
const dir = tmpDir();
|
|
599
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 3, maxAgents: 3 });
|
|
600
|
+
|
|
601
|
+
const result = await engine.run('architecture testing design', {}, {
|
|
602
|
+
onAgentTurn: mockOnAgentTurn,
|
|
603
|
+
onSynthesize: mockOnSynthesize
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
assert.equal(result.rounds, 3);
|
|
607
|
+
assert.equal(result.history.length, 9); // 3 rounds × 3 agents
|
|
608
|
+
assert.equal(result.agents.length, 3);
|
|
609
|
+
assert.ok(result.synthesis);
|
|
610
|
+
assert.ok(result.sessionId);
|
|
611
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('party with checkpoint resume', async () => {
|
|
615
|
+
const dir = tmpDir();
|
|
616
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 3, maxAgents: 1 });
|
|
617
|
+
|
|
618
|
+
// Run and stop after round 1
|
|
619
|
+
const result1 = await engine.run('testing', {}, {
|
|
620
|
+
onAgentTurn: mockOnAgentTurn,
|
|
621
|
+
onRoundComplete: ({ completedRound }) => completedRound === 1 ? 'stop' : 'continue'
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
assert.equal(result1.rounds, 1);
|
|
625
|
+
|
|
626
|
+
// Resume from checkpoint
|
|
627
|
+
const result2 = await engine.resume(result1.sessionId, {
|
|
628
|
+
onAgentTurn: mockOnAgentTurn,
|
|
629
|
+
onSynthesize: mockOnSynthesize
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// Should have continued from round 2 to round 3
|
|
633
|
+
assert.equal(result2.rounds, 3);
|
|
634
|
+
// Total history: round 1 (from checkpoint) + rounds 2,3 (from resume)
|
|
635
|
+
assert.equal(result2.history.length, 3);
|
|
636
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('party with early stop after round 1', async () => {
|
|
640
|
+
const dir = tmpDir();
|
|
641
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 5, maxAgents: 2 });
|
|
642
|
+
|
|
643
|
+
const result = await engine.run('testing', {}, {
|
|
644
|
+
onAgentTurn: mockOnAgentTurn,
|
|
645
|
+
onSynthesize: mockOnSynthesize,
|
|
646
|
+
onRoundComplete: () => 'stop'
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
assert.equal(result.rounds, 1);
|
|
650
|
+
assert.equal(result.history.length, 2); // 1 round × 2 agents
|
|
651
|
+
assert.ok(result.synthesis.consensus.length > 0);
|
|
652
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('party with add_round extends to 4', async () => {
|
|
656
|
+
const dir = tmpDir();
|
|
657
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 3, maxAgents: 1 });
|
|
658
|
+
|
|
659
|
+
let added = false;
|
|
660
|
+
const result = await engine.run('testing', {}, {
|
|
661
|
+
onAgentTurn: mockOnAgentTurn,
|
|
662
|
+
onRoundComplete: ({ completedRound }) => {
|
|
663
|
+
if (completedRound === 3 && !added) {
|
|
664
|
+
added = true;
|
|
665
|
+
return 'add_round';
|
|
666
|
+
}
|
|
667
|
+
return 'continue';
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
assert.equal(result.rounds, 4);
|
|
672
|
+
assert.equal(result.history.length, 4); // 4 rounds × 1 agent
|
|
673
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('party with manual agent override', async () => {
|
|
677
|
+
const dir = tmpDir();
|
|
678
|
+
const engine = new PartyEngine({ checkpointDir: dir, maxRounds: 1 });
|
|
679
|
+
|
|
680
|
+
const result = await engine.run('anything', { agents: ['tech-writer', 'verifier'] }, {
|
|
681
|
+
onAgentTurn: mockOnAgentTurn,
|
|
682
|
+
onSynthesize: mockOnSynthesize
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
assert.equal(result.agents.length, 2);
|
|
686
|
+
assert.equal(result.agents[0].id, 'tech-writer');
|
|
687
|
+
assert.equal(result.agents[1].id, 'verifier');
|
|
688
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it('resume requires onAgentTurn callback', async () => {
|
|
692
|
+
const engine = new PartyEngine();
|
|
693
|
+
await assert.rejects(
|
|
694
|
+
() => engine.resume('some-session', {}),
|
|
695
|
+
{ message: /requires callbacks.onAgentTurn/ }
|
|
696
|
+
);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('resume throws for missing checkpoint', async () => {
|
|
700
|
+
const dir = tmpDir();
|
|
701
|
+
const engine = new PartyEngine({ checkpointDir: dir });
|
|
702
|
+
|
|
703
|
+
await assert.rejects(
|
|
704
|
+
() => engine.resume('nonexistent-session', { onAgentTurn: mockOnAgentTurn }),
|
|
705
|
+
{ message: /No checkpoint found/ }
|
|
706
|
+
);
|
|
707
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// ── Helper function tests ────────────────────────────────────────────────────
|
|
712
|
+
|
|
713
|
+
describe('Helper functions', () => {
|
|
714
|
+
it('detectAgentNames finds names in text', () => {
|
|
715
|
+
assert.deepEqual(detectAgentNames('ask Sherlock'), ['debugger']);
|
|
716
|
+
assert.deepEqual(detectAgentNames('Murat and Sally'), ['qa', 'ux-designer']);
|
|
717
|
+
assert.deepEqual(detectAgentNames('nothing here'), []);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('detectAgentNames is case-insensitive', () => {
|
|
721
|
+
assert.deepEqual(detectAgentNames('SHERLOCK'), ['debugger']);
|
|
722
|
+
assert.deepEqual(detectAgentNames('murat'), ['qa']);
|
|
723
|
+
});
|
|
724
|
+
});
|