mcp-rubber-duck 1.9.5 → 1.11.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/.eslintrc.json +3 -1
- package/CHANGELOG.md +19 -0
- package/README.md +62 -10
- package/assets/ext-apps-compare.png +0 -0
- package/assets/ext-apps-debate.png +0 -0
- package/assets/ext-apps-usage-stats.png +0 -0
- package/assets/ext-apps-vote.png +0 -0
- package/audit-ci.json +2 -1
- package/dist/providers/enhanced-manager.d.ts +7 -0
- package/dist/providers/enhanced-manager.d.ts.map +1 -1
- package/dist/providers/enhanced-manager.js +36 -0
- package/dist/providers/enhanced-manager.js.map +1 -1
- package/dist/providers/manager.d.ts +1 -0
- package/dist/providers/manager.d.ts.map +1 -1
- package/dist/providers/manager.js +33 -0
- package/dist/providers/manager.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +154 -36
- package/dist/server.js.map +1 -1
- package/dist/services/progress.d.ts +27 -0
- package/dist/services/progress.d.ts.map +1 -0
- package/dist/services/progress.js +50 -0
- package/dist/services/progress.js.map +1 -0
- package/dist/services/task-manager.d.ts +56 -0
- package/dist/services/task-manager.d.ts.map +1 -0
- package/dist/services/task-manager.js +134 -0
- package/dist/services/task-manager.js.map +1 -0
- package/dist/tools/compare-ducks.d.ts +2 -1
- package/dist/tools/compare-ducks.d.ts.map +1 -1
- package/dist/tools/compare-ducks.js +26 -3
- package/dist/tools/compare-ducks.js.map +1 -1
- package/dist/tools/duck-council.d.ts +2 -1
- package/dist/tools/duck-council.d.ts.map +1 -1
- package/dist/tools/duck-council.js +7 -3
- package/dist/tools/duck-council.js.map +1 -1
- package/dist/tools/duck-debate.d.ts +2 -1
- package/dist/tools/duck-debate.d.ts.map +1 -1
- package/dist/tools/duck-debate.js +43 -1
- package/dist/tools/duck-debate.js.map +1 -1
- package/dist/tools/duck-iterate.d.ts +2 -1
- package/dist/tools/duck-iterate.d.ts.map +1 -1
- package/dist/tools/duck-iterate.js +13 -1
- package/dist/tools/duck-iterate.js.map +1 -1
- package/dist/tools/duck-vote.d.ts +2 -1
- package/dist/tools/duck-vote.d.ts.map +1 -1
- package/dist/tools/duck-vote.js +30 -3
- package/dist/tools/duck-vote.js.map +1 -1
- package/dist/tools/get-usage-stats.d.ts.map +1 -1
- package/dist/tools/get-usage-stats.js +13 -0
- package/dist/tools/get-usage-stats.js.map +1 -1
- package/dist/ui/compare-ducks/mcp-app.html +187 -0
- package/dist/ui/duck-debate/mcp-app.html +182 -0
- package/dist/ui/duck-vote/mcp-app.html +168 -0
- package/dist/ui/usage-stats/mcp-app.html +192 -0
- package/jest.config.js +1 -0
- package/package.json +7 -3
- package/src/providers/enhanced-manager.ts +49 -0
- package/src/providers/manager.ts +45 -0
- package/src/server.ts +187 -34
- package/src/services/progress.ts +59 -0
- package/src/services/task-manager.ts +162 -0
- package/src/tools/compare-ducks.ts +34 -3
- package/src/tools/duck-council.ts +15 -4
- package/src/tools/duck-debate.ts +58 -1
- package/src/tools/duck-iterate.ts +20 -1
- package/src/tools/duck-vote.ts +38 -3
- package/src/tools/get-usage-stats.ts +14 -0
- package/src/ui/compare-ducks/app.ts +88 -0
- package/src/ui/compare-ducks/mcp-app.html +102 -0
- package/src/ui/duck-debate/app.ts +111 -0
- package/src/ui/duck-debate/mcp-app.html +97 -0
- package/src/ui/duck-vote/app.ts +128 -0
- package/src/ui/duck-vote/mcp-app.html +83 -0
- package/src/ui/usage-stats/app.ts +156 -0
- package/src/ui/usage-stats/mcp-app.html +107 -0
- package/tests/duck-debate.test.ts +83 -1
- package/tests/duck-iterate.test.ts +81 -0
- package/tests/duck-vote.test.ts +73 -1
- package/tests/providers.test.ts +121 -0
- package/tests/services/progress.test.ts +137 -0
- package/tests/services/task-manager.test.ts +344 -0
- package/tests/tools/compare-ducks-ui.test.ts +135 -0
- package/tests/tools/compare-ducks.test.ts +22 -1
- package/tests/tools/duck-council.test.ts +19 -0
- package/tests/tools/duck-debate-ui.test.ts +234 -0
- package/tests/tools/duck-vote-ui.test.ts +172 -0
- package/tests/tools/get-usage-stats.test.ts +3 -1
- package/tests/tools/usage-stats-ui.test.ts +130 -0
- package/tests/ui-build.test.ts +53 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Usage Stats</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
11
|
+
line-height: 1.5;
|
|
12
|
+
color: #1a1a2e;
|
|
13
|
+
background: #f8f9fa;
|
|
14
|
+
padding: 16px;
|
|
15
|
+
}
|
|
16
|
+
.header { text-align: center; margin-bottom: 20px; }
|
|
17
|
+
.header h2 { margin-bottom: 4px; }
|
|
18
|
+
.period-badge {
|
|
19
|
+
display: inline-block;
|
|
20
|
+
background: #e3f2fd;
|
|
21
|
+
color: #1565c0;
|
|
22
|
+
padding: 2px 14px;
|
|
23
|
+
border-radius: 16px;
|
|
24
|
+
font-weight: 600;
|
|
25
|
+
font-size: 0.85em;
|
|
26
|
+
margin-bottom: 4px;
|
|
27
|
+
}
|
|
28
|
+
.date-range { font-size: 0.8em; opacity: 0.6; }
|
|
29
|
+
.summary-cards {
|
|
30
|
+
display: flex;
|
|
31
|
+
gap: 12px;
|
|
32
|
+
flex-wrap: wrap;
|
|
33
|
+
justify-content: center;
|
|
34
|
+
margin-bottom: 24px;
|
|
35
|
+
}
|
|
36
|
+
.card {
|
|
37
|
+
background: #fff;
|
|
38
|
+
border: 1px solid #e0e0e0;
|
|
39
|
+
border-radius: 12px;
|
|
40
|
+
padding: 12px 20px;
|
|
41
|
+
text-align: center;
|
|
42
|
+
min-width: 120px;
|
|
43
|
+
flex: 1;
|
|
44
|
+
}
|
|
45
|
+
.card-value { font-size: 1.4em; font-weight: 700; }
|
|
46
|
+
.card-label { font-size: 0.8em; opacity: 0.6; }
|
|
47
|
+
.card-req .card-value { color: #1565c0; }
|
|
48
|
+
.card-tok .card-value { color: #6a1b9a; }
|
|
49
|
+
.card-cache .card-value { color: #2e7d32; }
|
|
50
|
+
.card-err .card-value { color: #c62828; }
|
|
51
|
+
.card-ok .card-value { color: #2e7d32; }
|
|
52
|
+
.card-cost .card-value { color: #e65100; }
|
|
53
|
+
.section { margin-bottom: 20px; }
|
|
54
|
+
.section h3 { margin-bottom: 12px; font-size: 1em; }
|
|
55
|
+
.provider-row {
|
|
56
|
+
background: #fff;
|
|
57
|
+
border-radius: 10px;
|
|
58
|
+
margin-bottom: 8px;
|
|
59
|
+
overflow: hidden;
|
|
60
|
+
}
|
|
61
|
+
.provider-row summary {
|
|
62
|
+
padding: 10px 16px;
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
display: flex;
|
|
65
|
+
justify-content: space-between;
|
|
66
|
+
align-items: center;
|
|
67
|
+
font-weight: 600;
|
|
68
|
+
}
|
|
69
|
+
.provider-stats { font-weight: 400; font-size: 0.85em; opacity: 0.7; }
|
|
70
|
+
.token-bar { height: 6px; background: #e0e0e0; margin: 0 16px 8px; border-radius: 3px; overflow: hidden; }
|
|
71
|
+
.token-fill { height: 100%; background: linear-gradient(90deg, #42a5f5, #7e57c2); border-radius: 3px; }
|
|
72
|
+
.model-table { width: 100%; border-collapse: collapse; font-size: 0.85em; margin: 0 0 8px; }
|
|
73
|
+
.model-table th {
|
|
74
|
+
text-align: left;
|
|
75
|
+
padding: 6px 12px;
|
|
76
|
+
background: #f5f5f5;
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
font-size: 0.9em;
|
|
79
|
+
}
|
|
80
|
+
.model-table td { padding: 6px 12px; border-top: 1px solid #eee; }
|
|
81
|
+
.err-text { color: #c62828; font-weight: 600; }
|
|
82
|
+
.empty { text-align: center; padding: 24px; opacity: 0.5; }
|
|
83
|
+
.error-banner { background: #ffebee; color: #c62828; padding: 12px; border-radius: 8px; text-align: center; font-weight: 600; }
|
|
84
|
+
@media (prefers-color-scheme: dark) {
|
|
85
|
+
body { background: #1a1a2e; color: #e0e0e0; }
|
|
86
|
+
.card { background: #16213e; border-color: #0f3460; color: #e0e0e0; }
|
|
87
|
+
.card-req .card-value { color: #64b5f6; }
|
|
88
|
+
.card-tok .card-value { color: #ce93d8; }
|
|
89
|
+
.card-cache .card-value { color: #81c784; }
|
|
90
|
+
.card-err .card-value { color: #ef5350; }
|
|
91
|
+
.card-ok .card-value { color: #81c784; }
|
|
92
|
+
.card-cost .card-value { color: #ffb74d; }
|
|
93
|
+
.period-badge { background: #0f3460; color: #a0c4ff; }
|
|
94
|
+
.provider-row { background: #16213e; color: #e0e0e0; }
|
|
95
|
+
.model-table th { background: #0d1b2a; color: #b0b0b0; }
|
|
96
|
+
.model-table td { border-color: #0f3460; color: #d0d0d0; }
|
|
97
|
+
.token-bar { background: #0d1b2a; }
|
|
98
|
+
.err-text { color: #ef5350; }
|
|
99
|
+
.error-banner { background: #5c1a1a; color: #ff8a80; }
|
|
100
|
+
}
|
|
101
|
+
</style>
|
|
102
|
+
</head>
|
|
103
|
+
<body>
|
|
104
|
+
<div id="app"><p style="text-align:center;opacity:0.5">Waiting for results...</p></div>
|
|
105
|
+
<script type="module" src="./app.ts"></script>
|
|
106
|
+
</body>
|
|
107
|
+
</html>
|
|
@@ -163,7 +163,9 @@ describe('duckDebateTool', () => {
|
|
|
163
163
|
rounds: 2,
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
-
expect(result.content).toHaveLength(
|
|
166
|
+
expect(result.content).toHaveLength(2);
|
|
167
|
+
expect(result.content[1].type).toBe('text');
|
|
168
|
+
expect(() => JSON.parse(result.content[1].text)).not.toThrow();
|
|
167
169
|
const text = result.content[0].text;
|
|
168
170
|
|
|
169
171
|
expect(text).toContain('Oxford Debate');
|
|
@@ -387,4 +389,84 @@ describe('duckDebateTool', () => {
|
|
|
387
389
|
// Should not contain the full 900 A's
|
|
388
390
|
expect(text).not.toContain('A'.repeat(900));
|
|
389
391
|
});
|
|
392
|
+
|
|
393
|
+
it('should throw when signal is already aborted before starting', async () => {
|
|
394
|
+
const controller = new AbortController();
|
|
395
|
+
controller.abort();
|
|
396
|
+
|
|
397
|
+
await expect(
|
|
398
|
+
duckDebateTool(mockProviderManager, {
|
|
399
|
+
prompt: 'Test',
|
|
400
|
+
format: 'oxford',
|
|
401
|
+
}, undefined, controller.signal)
|
|
402
|
+
).rejects.toThrow('Task cancelled');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should throw when signal is aborted between rounds', async () => {
|
|
406
|
+
const controller = new AbortController();
|
|
407
|
+
let callCount = 0;
|
|
408
|
+
|
|
409
|
+
// Use mockImplementation so we can abort after round 1 completes
|
|
410
|
+
mockCreate.mockImplementation(async () => {
|
|
411
|
+
callCount++;
|
|
412
|
+
// After both participants in round 1 finish (2 calls), abort
|
|
413
|
+
if (callCount === 2) {
|
|
414
|
+
controller.abort();
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
choices: [{ message: { content: `Response ${callCount}` }, finish_reason: 'stop' }],
|
|
418
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
419
|
+
model: 'gpt-4',
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await expect(
|
|
424
|
+
duckDebateTool(mockProviderManager, {
|
|
425
|
+
prompt: 'Test',
|
|
426
|
+
format: 'oxford',
|
|
427
|
+
rounds: 3,
|
|
428
|
+
}, undefined, controller.signal)
|
|
429
|
+
).rejects.toThrow('Task cancelled');
|
|
430
|
+
|
|
431
|
+
// Only round 1 calls (2 participants), round 2 was never started
|
|
432
|
+
expect(mockCreate).toHaveBeenCalledTimes(2);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('should report progress when a ProgressReporter is provided', async () => {
|
|
436
|
+
const mockProgress = {
|
|
437
|
+
enabled: true,
|
|
438
|
+
report: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
// 1 round, 2 participants + synthesis = 3 calls
|
|
442
|
+
mockCreate
|
|
443
|
+
.mockResolvedValueOnce({
|
|
444
|
+
choices: [{ message: { content: 'PRO' }, finish_reason: 'stop' }],
|
|
445
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
446
|
+
model: 'gpt-4',
|
|
447
|
+
})
|
|
448
|
+
.mockResolvedValueOnce({
|
|
449
|
+
choices: [{ message: { content: 'CON' }, finish_reason: 'stop' }],
|
|
450
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
451
|
+
model: 'gemini-pro',
|
|
452
|
+
})
|
|
453
|
+
.mockResolvedValueOnce({
|
|
454
|
+
choices: [{ message: { content: 'Synthesis' }, finish_reason: 'stop' }],
|
|
455
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
456
|
+
model: 'gpt-4',
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
await duckDebateTool(mockProviderManager, {
|
|
460
|
+
prompt: 'Test',
|
|
461
|
+
format: 'oxford',
|
|
462
|
+
rounds: 1,
|
|
463
|
+
}, mockProgress);
|
|
464
|
+
|
|
465
|
+
// 2 participants + 1 synthesis = 3 progress reports
|
|
466
|
+
expect(mockProgress.report).toHaveBeenCalledTimes(3);
|
|
467
|
+
// Total steps = 1 round * 2 participants + 1 synthesis = 3
|
|
468
|
+
expect(mockProgress.report).toHaveBeenNthCalledWith(1, 1, 3, expect.stringContaining('Round 1/1'));
|
|
469
|
+
expect(mockProgress.report).toHaveBeenNthCalledWith(2, 2, 3, expect.stringContaining('Round 1/1'));
|
|
470
|
+
expect(mockProgress.report).toHaveBeenNthCalledWith(3, 3, 3, 'Synthesis complete');
|
|
471
|
+
});
|
|
390
472
|
});
|
|
@@ -276,4 +276,85 @@ describe('duckIterateTool', () => {
|
|
|
276
276
|
// Final response section should have the short refined response
|
|
277
277
|
expect(text).toContain('Short refined response');
|
|
278
278
|
});
|
|
279
|
+
|
|
280
|
+
it('should throw when signal is already aborted', async () => {
|
|
281
|
+
const controller = new AbortController();
|
|
282
|
+
controller.abort();
|
|
283
|
+
|
|
284
|
+
await expect(
|
|
285
|
+
duckIterateTool(mockProviderManager, {
|
|
286
|
+
prompt: 'Test',
|
|
287
|
+
providers: ['openai', 'gemini'],
|
|
288
|
+
mode: 'refine',
|
|
289
|
+
}, undefined, controller.signal)
|
|
290
|
+
).rejects.toThrow('Task cancelled');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should throw when signal is aborted between iterations', async () => {
|
|
294
|
+
const controller = new AbortController();
|
|
295
|
+
let callCount = 0;
|
|
296
|
+
|
|
297
|
+
// Use mockImplementation so we can abort after round 1 completes
|
|
298
|
+
mockCreate.mockImplementation(async () => {
|
|
299
|
+
callCount++;
|
|
300
|
+
// After round 1 (first call = initial generation), abort
|
|
301
|
+
if (callCount === 1) {
|
|
302
|
+
controller.abort();
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
choices: [{ message: { content: `Response ${callCount}` }, finish_reason: 'stop' }],
|
|
306
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
307
|
+
model: 'gpt-4',
|
|
308
|
+
};
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
await expect(
|
|
312
|
+
duckIterateTool(mockProviderManager, {
|
|
313
|
+
prompt: 'Test',
|
|
314
|
+
providers: ['openai', 'gemini'],
|
|
315
|
+
mode: 'refine',
|
|
316
|
+
iterations: 3,
|
|
317
|
+
}, undefined, controller.signal)
|
|
318
|
+
).rejects.toThrow('Task cancelled');
|
|
319
|
+
|
|
320
|
+
// Only 1 call (initial generation), round 2 was never started
|
|
321
|
+
expect(mockCreate).toHaveBeenCalledTimes(1);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should report progress when a ProgressReporter is provided', async () => {
|
|
325
|
+
const mockProgress = {
|
|
326
|
+
enabled: true,
|
|
327
|
+
report: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
mockCreate
|
|
331
|
+
.mockResolvedValueOnce({
|
|
332
|
+
choices: [{ message: { content: 'Initial response' }, finish_reason: 'stop' }],
|
|
333
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
334
|
+
model: 'gpt-4',
|
|
335
|
+
})
|
|
336
|
+
.mockResolvedValueOnce({
|
|
337
|
+
choices: [{ message: { content: 'Refined response' }, finish_reason: 'stop' }],
|
|
338
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
339
|
+
model: 'gemini-pro',
|
|
340
|
+
})
|
|
341
|
+
.mockResolvedValueOnce({
|
|
342
|
+
choices: [{ message: { content: 'Final refinement' }, finish_reason: 'stop' }],
|
|
343
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
344
|
+
model: 'gpt-4',
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await duckIterateTool(mockProviderManager, {
|
|
348
|
+
prompt: 'Test prompt',
|
|
349
|
+
providers: ['openai', 'gemini'],
|
|
350
|
+
mode: 'refine',
|
|
351
|
+
iterations: 3,
|
|
352
|
+
}, mockProgress);
|
|
353
|
+
|
|
354
|
+
// 3 rounds = 3 progress reports
|
|
355
|
+
expect(mockProgress.report).toHaveBeenCalledTimes(3);
|
|
356
|
+
expect(mockProgress.report).toHaveBeenNthCalledWith(1, 1, 3, expect.stringContaining('Round 1/3'));
|
|
357
|
+
expect(mockProgress.report).toHaveBeenNthCalledWith(2, 2, 3, expect.stringContaining('Round 2/3'));
|
|
358
|
+
expect(mockProgress.report).toHaveBeenNthCalledWith(3, 3, 3, expect.stringContaining('Round 3/3'));
|
|
359
|
+
});
|
|
279
360
|
});
|
package/tests/duck-vote.test.ts
CHANGED
|
@@ -115,8 +115,10 @@ describe('duckVoteTool', () => {
|
|
|
115
115
|
options: ['Option A', 'Option B'],
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
expect(result.content).toHaveLength(
|
|
118
|
+
expect(result.content).toHaveLength(2);
|
|
119
119
|
expect(result.content[0].type).toBe('text');
|
|
120
|
+
expect(result.content[1].type).toBe('text');
|
|
121
|
+
expect(() => JSON.parse(result.content[1].text)).not.toThrow();
|
|
120
122
|
|
|
121
123
|
const text = result.content[0].text;
|
|
122
124
|
expect(text).toContain('Vote Results');
|
|
@@ -264,6 +266,76 @@ describe('duckVoteTool', () => {
|
|
|
264
266
|
mockProviderManager.getProviderNames = originalGetProviderNames;
|
|
265
267
|
});
|
|
266
268
|
|
|
269
|
+
it('should use compareDucksWithProgress when progress is provided', async () => {
|
|
270
|
+
const mockProgress = {
|
|
271
|
+
enabled: true,
|
|
272
|
+
report: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
mockCreate
|
|
276
|
+
.mockResolvedValueOnce({
|
|
277
|
+
choices: [{
|
|
278
|
+
message: { content: '{"choice": "Option A", "confidence": 80, "reasoning": "Good"}' },
|
|
279
|
+
finish_reason: 'stop',
|
|
280
|
+
}],
|
|
281
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
282
|
+
model: 'gpt-4',
|
|
283
|
+
})
|
|
284
|
+
.mockResolvedValueOnce({
|
|
285
|
+
choices: [{
|
|
286
|
+
message: { content: '{"choice": "Option A", "confidence": 75, "reasoning": "Also good"}' },
|
|
287
|
+
finish_reason: 'stop',
|
|
288
|
+
}],
|
|
289
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
290
|
+
model: 'gemini-pro',
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await duckVoteTool(mockProviderManager, {
|
|
294
|
+
question: 'Best approach?',
|
|
295
|
+
options: ['Option A', 'Option B'],
|
|
296
|
+
}, mockProgress);
|
|
297
|
+
|
|
298
|
+
// 2 voters = 2 progress reports
|
|
299
|
+
expect(mockProgress.report).toHaveBeenCalledTimes(2);
|
|
300
|
+
expect(mockProgress.report).toHaveBeenNthCalledWith(1, 1, 2, expect.stringContaining('voted'));
|
|
301
|
+
expect(mockProgress.report).toHaveBeenNthCalledWith(2, 2, 2, expect.stringContaining('voted'));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should use compareDucks when no progress is provided', async () => {
|
|
305
|
+
mockCreate
|
|
306
|
+
.mockResolvedValueOnce({
|
|
307
|
+
choices: [{
|
|
308
|
+
message: { content: '{"choice": "Option A", "confidence": 80}' },
|
|
309
|
+
finish_reason: 'stop',
|
|
310
|
+
}],
|
|
311
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
312
|
+
model: 'gpt-4',
|
|
313
|
+
})
|
|
314
|
+
.mockResolvedValueOnce({
|
|
315
|
+
choices: [{
|
|
316
|
+
message: { content: '{"choice": "Option A", "confidence": 75}' },
|
|
317
|
+
finish_reason: 'stop',
|
|
318
|
+
}],
|
|
319
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
320
|
+
model: 'gemini-pro',
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Spy on compareDucks to ensure it's called (not compareDucksWithProgress)
|
|
324
|
+
const compareDucksSpy = jest.spyOn(mockProviderManager, 'compareDucks');
|
|
325
|
+
const compareDucksWithProgressSpy = jest.spyOn(mockProviderManager, 'compareDucksWithProgress');
|
|
326
|
+
|
|
327
|
+
await duckVoteTool(mockProviderManager, {
|
|
328
|
+
question: 'Best approach?',
|
|
329
|
+
options: ['Option A', 'Option B'],
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(compareDucksSpy).toHaveBeenCalled();
|
|
333
|
+
expect(compareDucksWithProgressSpy).not.toHaveBeenCalled();
|
|
334
|
+
|
|
335
|
+
compareDucksSpy.mockRestore();
|
|
336
|
+
compareDucksWithProgressSpy.mockRestore();
|
|
337
|
+
});
|
|
338
|
+
|
|
267
339
|
it('should handle case when no valid votes result in no winner', async () => {
|
|
268
340
|
// Both responses don't mention any valid option
|
|
269
341
|
mockCreate
|
package/tests/providers.test.ts
CHANGED
|
@@ -371,6 +371,127 @@ describe('ProviderManager', () => {
|
|
|
371
371
|
});
|
|
372
372
|
});
|
|
373
373
|
|
|
374
|
+
describe('ProviderManager compareDucksWithProgress', () => {
|
|
375
|
+
let manager: ProviderManager;
|
|
376
|
+
let mockConfigManager: jest.Mocked<ConfigManager>;
|
|
377
|
+
|
|
378
|
+
beforeEach(() => {
|
|
379
|
+
jest.clearAllMocks();
|
|
380
|
+
|
|
381
|
+
mockCreate.mockResolvedValue({
|
|
382
|
+
choices: [{
|
|
383
|
+
message: { content: 'Mocked response' },
|
|
384
|
+
finish_reason: 'stop',
|
|
385
|
+
}],
|
|
386
|
+
usage: {
|
|
387
|
+
prompt_tokens: 10,
|
|
388
|
+
completion_tokens: 20,
|
|
389
|
+
total_tokens: 30,
|
|
390
|
+
},
|
|
391
|
+
model: 'mock-model',
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
mockConfigManager = {
|
|
395
|
+
getConfig: jest.fn().mockReturnValue({
|
|
396
|
+
providers: {
|
|
397
|
+
test1: {
|
|
398
|
+
api_key: 'key1',
|
|
399
|
+
base_url: 'https://api1.test.com/v1',
|
|
400
|
+
default_model: 'model1',
|
|
401
|
+
nickname: 'Duck 1',
|
|
402
|
+
models: ['model1'],
|
|
403
|
+
},
|
|
404
|
+
test2: {
|
|
405
|
+
api_key: 'key2',
|
|
406
|
+
base_url: 'https://api2.test.com/v1',
|
|
407
|
+
default_model: 'model2',
|
|
408
|
+
nickname: 'Duck 2',
|
|
409
|
+
models: ['model2'],
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
default_provider: 'test1',
|
|
413
|
+
cache_ttl: 300,
|
|
414
|
+
enable_failover: true,
|
|
415
|
+
default_temperature: 0.7,
|
|
416
|
+
}),
|
|
417
|
+
} as any;
|
|
418
|
+
|
|
419
|
+
manager = new ProviderManager(mockConfigManager);
|
|
420
|
+
|
|
421
|
+
const provider1 = manager.getProvider('test1');
|
|
422
|
+
const provider2 = manager.getProvider('test2');
|
|
423
|
+
provider1['client'].chat.completions.create = mockCreate;
|
|
424
|
+
provider2['client'].chat.completions.create = mockCreate;
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should call onProviderComplete for each provider', async () => {
|
|
428
|
+
const onComplete = jest.fn();
|
|
429
|
+
|
|
430
|
+
await manager.compareDucksWithProgress('Hello', ['test1', 'test2'], undefined, onComplete);
|
|
431
|
+
|
|
432
|
+
expect(onComplete).toHaveBeenCalledTimes(2);
|
|
433
|
+
// First call: completed=1, total=2
|
|
434
|
+
expect(onComplete).toHaveBeenNthCalledWith(1, expect.any(String), 1, 2);
|
|
435
|
+
// Second call: completed=2, total=2
|
|
436
|
+
expect(onComplete).toHaveBeenNthCalledWith(2, expect.any(String), 2, 2);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should return responses from all providers', async () => {
|
|
440
|
+
const onComplete = jest.fn();
|
|
441
|
+
|
|
442
|
+
const responses = await manager.compareDucksWithProgress('Hello', ['test1', 'test2'], undefined, onComplete);
|
|
443
|
+
|
|
444
|
+
expect(responses).toHaveLength(2);
|
|
445
|
+
expect(responses[0].provider).toBe('test1');
|
|
446
|
+
expect(responses[1].provider).toBe('test2');
|
|
447
|
+
expect(responses[0].content).toBe('Mocked response');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('should use all providers when providerNames is undefined', async () => {
|
|
451
|
+
const onComplete = jest.fn();
|
|
452
|
+
|
|
453
|
+
const responses = await manager.compareDucksWithProgress('Hello', undefined, undefined, onComplete);
|
|
454
|
+
|
|
455
|
+
expect(responses).toHaveLength(2);
|
|
456
|
+
expect(onComplete).toHaveBeenCalledTimes(2);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('should call onProviderComplete even when a provider errors', async () => {
|
|
460
|
+
const provider1 = manager.getProvider('test1');
|
|
461
|
+
provider1['client'].chat.completions.create = jest.fn().mockRejectedValue(new Error('API Error'));
|
|
462
|
+
|
|
463
|
+
const onComplete = jest.fn();
|
|
464
|
+
|
|
465
|
+
const responses = await manager.compareDucksWithProgress('Hello', ['test1', 'test2'], undefined, onComplete);
|
|
466
|
+
|
|
467
|
+
// Both callbacks should fire (error path included via .catch().then())
|
|
468
|
+
expect(onComplete).toHaveBeenCalledTimes(2);
|
|
469
|
+
expect(responses).toHaveLength(2);
|
|
470
|
+
expect(responses[0].content).toContain('Error');
|
|
471
|
+
expect(responses[1].content).toBe('Mocked response');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('should throw when no valid providers specified', async () => {
|
|
475
|
+
const onComplete = jest.fn();
|
|
476
|
+
|
|
477
|
+
await expect(
|
|
478
|
+
manager.compareDucksWithProgress('Hello', ['nonexistent'], undefined, onComplete)
|
|
479
|
+
).rejects.toThrow('No valid providers specified');
|
|
480
|
+
|
|
481
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('should pass options through to askDuck', async () => {
|
|
485
|
+
const onComplete = jest.fn();
|
|
486
|
+
const askDuckSpy = jest.spyOn(manager, 'askDuck');
|
|
487
|
+
|
|
488
|
+
await manager.compareDucksWithProgress('Hello', ['test1'], { model: 'custom-model' }, onComplete);
|
|
489
|
+
|
|
490
|
+
expect(askDuckSpy).toHaveBeenCalledWith('test1', 'Hello', { model: 'custom-model' });
|
|
491
|
+
askDuckSpy.mockRestore();
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
374
495
|
describe('ProviderManager Error Cases', () => {
|
|
375
496
|
it('should throw error when no default provider and none specified', () => {
|
|
376
497
|
const mockConfigManager = {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, jest } from '@jest/globals';
|
|
2
|
+
import { createProgressReporter } from '../../src/services/progress.js';
|
|
3
|
+
import type { ProgressReporter } from '../../src/services/progress.js';
|
|
4
|
+
|
|
5
|
+
describe('createProgressReporter', () => {
|
|
6
|
+
it('should return a disabled no-op reporter when progressToken is undefined', () => {
|
|
7
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
8
|
+
const reporter = createProgressReporter(undefined, sendNotification);
|
|
9
|
+
|
|
10
|
+
expect(reporter.enabled).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should not call sendNotification when progressToken is undefined', async () => {
|
|
14
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
15
|
+
const reporter = createProgressReporter(undefined, sendNotification);
|
|
16
|
+
|
|
17
|
+
await reporter.report(1, 10, 'test');
|
|
18
|
+
|
|
19
|
+
expect(sendNotification).not.toHaveBeenCalled();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should return an enabled reporter when progressToken is a string', () => {
|
|
23
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
24
|
+
const reporter = createProgressReporter('token-123', sendNotification);
|
|
25
|
+
|
|
26
|
+
expect(reporter.enabled).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return an enabled reporter when progressToken is a number', () => {
|
|
30
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
31
|
+
const reporter = createProgressReporter(42, sendNotification);
|
|
32
|
+
|
|
33
|
+
expect(reporter.enabled).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should send a progress notification with correct method and params', async () => {
|
|
37
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
38
|
+
const reporter = createProgressReporter('my-token', sendNotification);
|
|
39
|
+
|
|
40
|
+
await reporter.report(3, 10, 'Processing step 3');
|
|
41
|
+
|
|
42
|
+
expect(sendNotification).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(sendNotification).toHaveBeenCalledWith({
|
|
44
|
+
method: 'notifications/progress',
|
|
45
|
+
params: {
|
|
46
|
+
progressToken: 'my-token',
|
|
47
|
+
progress: 3,
|
|
48
|
+
total: 10,
|
|
49
|
+
message: 'Processing step 3',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should omit message field when message is undefined', async () => {
|
|
55
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
56
|
+
const reporter = createProgressReporter('tok', sendNotification);
|
|
57
|
+
|
|
58
|
+
await reporter.report(1, 5);
|
|
59
|
+
|
|
60
|
+
expect(sendNotification).toHaveBeenCalledWith({
|
|
61
|
+
method: 'notifications/progress',
|
|
62
|
+
params: {
|
|
63
|
+
progressToken: 'tok',
|
|
64
|
+
progress: 1,
|
|
65
|
+
total: 5,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should send multiple progress notifications for successive reports', async () => {
|
|
71
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
72
|
+
const reporter = createProgressReporter('tok', sendNotification);
|
|
73
|
+
|
|
74
|
+
await reporter.report(1, 3, 'A done');
|
|
75
|
+
await reporter.report(2, 3, 'B done');
|
|
76
|
+
await reporter.report(3, 3, 'C done');
|
|
77
|
+
|
|
78
|
+
expect(sendNotification).toHaveBeenCalledTimes(3);
|
|
79
|
+
|
|
80
|
+
// Verify increasing progress values
|
|
81
|
+
const calls = sendNotification.mock.calls as unknown as Array<[{ params: { progress: number } }]>;
|
|
82
|
+
expect(calls[0][0].params.progress).toBe(1);
|
|
83
|
+
expect(calls[1][0].params.progress).toBe(2);
|
|
84
|
+
expect(calls[2][0].params.progress).toBe(3);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should use a numeric progressToken correctly', async () => {
|
|
88
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
89
|
+
const reporter = createProgressReporter(99, sendNotification);
|
|
90
|
+
|
|
91
|
+
await reporter.report(1, 1);
|
|
92
|
+
|
|
93
|
+
expect(sendNotification).toHaveBeenCalledWith({
|
|
94
|
+
method: 'notifications/progress',
|
|
95
|
+
params: {
|
|
96
|
+
progressToken: 99,
|
|
97
|
+
progress: 1,
|
|
98
|
+
total: 1,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should swallow sendNotification errors without throwing', async () => {
|
|
104
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockRejectedValue(new Error('client disconnected'));
|
|
105
|
+
const reporter = createProgressReporter('tok', sendNotification);
|
|
106
|
+
|
|
107
|
+
// Should NOT throw — progress is best-effort
|
|
108
|
+
await expect(reporter.report(1, 5, 'step')).resolves.toBeUndefined();
|
|
109
|
+
expect(sendNotification).toHaveBeenCalledTimes(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should continue reporting after a notification error', async () => {
|
|
113
|
+
const sendNotification = jest.fn<() => Promise<void>>()
|
|
114
|
+
.mockRejectedValueOnce(new Error('transient error'))
|
|
115
|
+
.mockResolvedValueOnce(undefined);
|
|
116
|
+
const reporter = createProgressReporter('tok', sendNotification);
|
|
117
|
+
|
|
118
|
+
await reporter.report(1, 2, 'first');
|
|
119
|
+
await reporter.report(2, 2, 'second');
|
|
120
|
+
|
|
121
|
+
expect(sendNotification).toHaveBeenCalledTimes(2);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('ProgressReporter interface', () => {
|
|
126
|
+
it('should be easy to create a mock for testing tools', async () => {
|
|
127
|
+
// This tests the pattern tool tests will use
|
|
128
|
+
const mockReporter: ProgressReporter = {
|
|
129
|
+
enabled: true,
|
|
130
|
+
report: jest.fn<ProgressReporter['report']>().mockResolvedValue(undefined),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await mockReporter.report(1, 3, 'step 1');
|
|
134
|
+
|
|
135
|
+
expect(mockReporter.report).toHaveBeenCalledWith(1, 3, 'step 1');
|
|
136
|
+
});
|
|
137
|
+
});
|