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.
Files changed (92) hide show
  1. package/.eslintrc.json +3 -1
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +62 -10
  4. package/assets/ext-apps-compare.png +0 -0
  5. package/assets/ext-apps-debate.png +0 -0
  6. package/assets/ext-apps-usage-stats.png +0 -0
  7. package/assets/ext-apps-vote.png +0 -0
  8. package/audit-ci.json +2 -1
  9. package/dist/providers/enhanced-manager.d.ts +7 -0
  10. package/dist/providers/enhanced-manager.d.ts.map +1 -1
  11. package/dist/providers/enhanced-manager.js +36 -0
  12. package/dist/providers/enhanced-manager.js.map +1 -1
  13. package/dist/providers/manager.d.ts +1 -0
  14. package/dist/providers/manager.d.ts.map +1 -1
  15. package/dist/providers/manager.js +33 -0
  16. package/dist/providers/manager.js.map +1 -1
  17. package/dist/server.d.ts +2 -0
  18. package/dist/server.d.ts.map +1 -1
  19. package/dist/server.js +154 -36
  20. package/dist/server.js.map +1 -1
  21. package/dist/services/progress.d.ts +27 -0
  22. package/dist/services/progress.d.ts.map +1 -0
  23. package/dist/services/progress.js +50 -0
  24. package/dist/services/progress.js.map +1 -0
  25. package/dist/services/task-manager.d.ts +56 -0
  26. package/dist/services/task-manager.d.ts.map +1 -0
  27. package/dist/services/task-manager.js +134 -0
  28. package/dist/services/task-manager.js.map +1 -0
  29. package/dist/tools/compare-ducks.d.ts +2 -1
  30. package/dist/tools/compare-ducks.d.ts.map +1 -1
  31. package/dist/tools/compare-ducks.js +26 -3
  32. package/dist/tools/compare-ducks.js.map +1 -1
  33. package/dist/tools/duck-council.d.ts +2 -1
  34. package/dist/tools/duck-council.d.ts.map +1 -1
  35. package/dist/tools/duck-council.js +7 -3
  36. package/dist/tools/duck-council.js.map +1 -1
  37. package/dist/tools/duck-debate.d.ts +2 -1
  38. package/dist/tools/duck-debate.d.ts.map +1 -1
  39. package/dist/tools/duck-debate.js +43 -1
  40. package/dist/tools/duck-debate.js.map +1 -1
  41. package/dist/tools/duck-iterate.d.ts +2 -1
  42. package/dist/tools/duck-iterate.d.ts.map +1 -1
  43. package/dist/tools/duck-iterate.js +13 -1
  44. package/dist/tools/duck-iterate.js.map +1 -1
  45. package/dist/tools/duck-vote.d.ts +2 -1
  46. package/dist/tools/duck-vote.d.ts.map +1 -1
  47. package/dist/tools/duck-vote.js +30 -3
  48. package/dist/tools/duck-vote.js.map +1 -1
  49. package/dist/tools/get-usage-stats.d.ts.map +1 -1
  50. package/dist/tools/get-usage-stats.js +13 -0
  51. package/dist/tools/get-usage-stats.js.map +1 -1
  52. package/dist/ui/compare-ducks/mcp-app.html +187 -0
  53. package/dist/ui/duck-debate/mcp-app.html +182 -0
  54. package/dist/ui/duck-vote/mcp-app.html +168 -0
  55. package/dist/ui/usage-stats/mcp-app.html +192 -0
  56. package/jest.config.js +1 -0
  57. package/package.json +7 -3
  58. package/src/providers/enhanced-manager.ts +49 -0
  59. package/src/providers/manager.ts +45 -0
  60. package/src/server.ts +187 -34
  61. package/src/services/progress.ts +59 -0
  62. package/src/services/task-manager.ts +162 -0
  63. package/src/tools/compare-ducks.ts +34 -3
  64. package/src/tools/duck-council.ts +15 -4
  65. package/src/tools/duck-debate.ts +58 -1
  66. package/src/tools/duck-iterate.ts +20 -1
  67. package/src/tools/duck-vote.ts +38 -3
  68. package/src/tools/get-usage-stats.ts +14 -0
  69. package/src/ui/compare-ducks/app.ts +88 -0
  70. package/src/ui/compare-ducks/mcp-app.html +102 -0
  71. package/src/ui/duck-debate/app.ts +111 -0
  72. package/src/ui/duck-debate/mcp-app.html +97 -0
  73. package/src/ui/duck-vote/app.ts +128 -0
  74. package/src/ui/duck-vote/mcp-app.html +83 -0
  75. package/src/ui/usage-stats/app.ts +156 -0
  76. package/src/ui/usage-stats/mcp-app.html +107 -0
  77. package/tests/duck-debate.test.ts +83 -1
  78. package/tests/duck-iterate.test.ts +81 -0
  79. package/tests/duck-vote.test.ts +73 -1
  80. package/tests/providers.test.ts +121 -0
  81. package/tests/services/progress.test.ts +137 -0
  82. package/tests/services/task-manager.test.ts +344 -0
  83. package/tests/tools/compare-ducks-ui.test.ts +135 -0
  84. package/tests/tools/compare-ducks.test.ts +22 -1
  85. package/tests/tools/duck-council.test.ts +19 -0
  86. package/tests/tools/duck-debate-ui.test.ts +234 -0
  87. package/tests/tools/duck-vote-ui.test.ts +172 -0
  88. package/tests/tools/get-usage-stats.test.ts +3 -1
  89. package/tests/tools/usage-stats-ui.test.ts +130 -0
  90. package/tests/ui-build.test.ts +53 -0
  91. package/tsconfig.json +1 -1
  92. 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(1);
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
  });
@@ -115,8 +115,10 @@ describe('duckVoteTool', () => {
115
115
  options: ['Option A', 'Option B'],
116
116
  });
117
117
 
118
- expect(result.content).toHaveLength(1);
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
@@ -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
+ });