mcp-rubber-duck 1.10.0 → 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 (61) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +8 -0
  3. package/dist/providers/enhanced-manager.d.ts +7 -0
  4. package/dist/providers/enhanced-manager.d.ts.map +1 -1
  5. package/dist/providers/enhanced-manager.js +36 -0
  6. package/dist/providers/enhanced-manager.js.map +1 -1
  7. package/dist/providers/manager.d.ts +1 -0
  8. package/dist/providers/manager.d.ts.map +1 -1
  9. package/dist/providers/manager.js +33 -0
  10. package/dist/providers/manager.js.map +1 -1
  11. package/dist/server.d.ts +1 -0
  12. package/dist/server.d.ts.map +1 -1
  13. package/dist/server.js +93 -33
  14. package/dist/server.js.map +1 -1
  15. package/dist/services/progress.d.ts +27 -0
  16. package/dist/services/progress.d.ts.map +1 -0
  17. package/dist/services/progress.js +50 -0
  18. package/dist/services/progress.js.map +1 -0
  19. package/dist/services/task-manager.d.ts +56 -0
  20. package/dist/services/task-manager.d.ts.map +1 -0
  21. package/dist/services/task-manager.js +134 -0
  22. package/dist/services/task-manager.js.map +1 -0
  23. package/dist/tools/compare-ducks.d.ts +2 -1
  24. package/dist/tools/compare-ducks.d.ts.map +1 -1
  25. package/dist/tools/compare-ducks.js +7 -3
  26. package/dist/tools/compare-ducks.js.map +1 -1
  27. package/dist/tools/duck-council.d.ts +2 -1
  28. package/dist/tools/duck-council.d.ts.map +1 -1
  29. package/dist/tools/duck-council.js +7 -3
  30. package/dist/tools/duck-council.js.map +1 -1
  31. package/dist/tools/duck-debate.d.ts +2 -1
  32. package/dist/tools/duck-debate.d.ts.map +1 -1
  33. package/dist/tools/duck-debate.js +19 -1
  34. package/dist/tools/duck-debate.js.map +1 -1
  35. package/dist/tools/duck-iterate.d.ts +2 -1
  36. package/dist/tools/duck-iterate.d.ts.map +1 -1
  37. package/dist/tools/duck-iterate.js +13 -1
  38. package/dist/tools/duck-iterate.js.map +1 -1
  39. package/dist/tools/duck-vote.d.ts +2 -1
  40. package/dist/tools/duck-vote.d.ts.map +1 -1
  41. package/dist/tools/duck-vote.js +7 -3
  42. package/dist/tools/duck-vote.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/providers/enhanced-manager.ts +49 -0
  45. package/src/providers/manager.ts +45 -0
  46. package/src/server.ts +110 -32
  47. package/src/services/progress.ts +59 -0
  48. package/src/services/task-manager.ts +162 -0
  49. package/src/tools/compare-ducks.ts +14 -3
  50. package/src/tools/duck-council.ts +15 -4
  51. package/src/tools/duck-debate.ts +31 -1
  52. package/src/tools/duck-iterate.ts +20 -1
  53. package/src/tools/duck-vote.ts +14 -3
  54. package/tests/duck-debate.test.ts +80 -0
  55. package/tests/duck-iterate.test.ts +81 -0
  56. package/tests/duck-vote.test.ts +70 -0
  57. package/tests/providers.test.ts +121 -0
  58. package/tests/services/progress.test.ts +137 -0
  59. package/tests/services/task-manager.test.ts +344 -0
  60. package/tests/tools/compare-ducks.test.ts +19 -0
  61. package/tests/tools/duck-council.test.ts +19 -0
@@ -1,10 +1,12 @@
1
1
  import { ProviderManager } from '../providers/manager.js';
2
2
  import { duckArt, getRandomDuckMessage } from '../utils/ascii-art.js';
3
3
  import { logger } from '../utils/logger.js';
4
+ import type { ProgressReporter } from '../services/progress.js';
4
5
 
5
6
  export async function duckCouncilTool(
6
7
  providerManager: ProviderManager,
7
- args: Record<string, unknown>
8
+ args: Record<string, unknown>,
9
+ progress?: ProgressReporter
8
10
  ) {
9
11
  const { prompt, model } = args as {
10
12
  prompt?: string;
@@ -19,13 +21,22 @@ export async function duckCouncilTool(
19
21
 
20
22
  // Get all available ducks
21
23
  const allProviders = providerManager.getProviderNames();
22
-
24
+
23
25
  if (allProviders.length === 0) {
24
26
  throw new Error('No ducks available for the council!');
25
27
  }
26
28
 
27
- // Get responses from all ducks
28
- const responses = await providerManager.duckCouncil(prompt, { model });
29
+ // Get responses from all ducks, reporting progress as each completes
30
+ const responses = progress
31
+ ? await providerManager.compareDucksWithProgress(
32
+ prompt,
33
+ undefined,
34
+ { model },
35
+ (providerName, completed, total) => {
36
+ void progress.report(completed, total, `${providerName} responded (${completed}/${total})`);
37
+ }
38
+ )
39
+ : await providerManager.duckCouncil(prompt, { model });
29
40
 
30
41
  // Build council response with a panel discussion format
31
42
  let response = `${duckArt.panel}\n\n`;
@@ -7,6 +7,7 @@ import {
7
7
  DebateResult,
8
8
  } from '../config/types.js';
9
9
  import { logger } from '../utils/logger.js';
10
+ import type { ProgressReporter } from '../services/progress.js';
10
11
 
11
12
  export interface DuckDebateArgs {
12
13
  prompt: string;
@@ -20,7 +21,9 @@ const DEFAULT_ROUNDS = 3;
20
21
 
21
22
  export async function duckDebateTool(
22
23
  providerManager: ProviderManager,
23
- args: Record<string, unknown>
24
+ args: Record<string, unknown>,
25
+ progress?: ProgressReporter,
26
+ signal?: AbortSignal
24
27
  ) {
25
28
  const {
26
29
  prompt,
@@ -73,13 +76,23 @@ export async function duckDebateTool(
73
76
 
74
77
  // Run debate rounds
75
78
  const debateRounds: DebateArgument[][] = [];
79
+ const totalSteps = rounds * participants.length + 1; // +1 for synthesis
80
+ let completedSteps = 0;
76
81
 
77
82
  for (let roundNum = 1; roundNum <= rounds; roundNum++) {
83
+ if (signal?.aborted) {
84
+ throw new Error('Task cancelled');
85
+ }
86
+
78
87
  logger.info(`Debate round ${roundNum}/${rounds}`);
79
88
  const roundArguments: DebateArgument[] = [];
80
89
 
81
90
  // Each participant argues in this round
82
91
  for (const participant of participants) {
92
+ if (signal?.aborted) {
93
+ throw new Error('Task cancelled');
94
+ }
95
+
83
96
  const argumentPrompt = buildArgumentPrompt(
84
97
  prompt,
85
98
  format,
@@ -99,16 +112,33 @@ export async function duckDebateTool(
99
112
  content: response.content,
100
113
  timestamp: new Date(),
101
114
  });
115
+
116
+ completedSteps++;
117
+ if (progress) {
118
+ void progress.report(
119
+ completedSteps,
120
+ totalSteps,
121
+ `Round ${roundNum}/${rounds}: ${participant.nickname} (${participant.position})`
122
+ );
123
+ }
102
124
  }
103
125
 
104
126
  debateRounds.push(roundArguments);
105
127
  }
106
128
 
129
+ if (signal?.aborted) {
130
+ throw new Error('Task cancelled');
131
+ }
132
+
107
133
  // Generate synthesis
108
134
  const synthesizerProvider = synthesizer || debateProviders[0];
109
135
  const synthesisPrompt = buildSynthesisPrompt(prompt, format, debateRounds, participants);
110
136
  const synthesisResponse = await providerManager.askDuck(synthesizerProvider, synthesisPrompt);
111
137
 
138
+ if (progress) {
139
+ void progress.report(totalSteps, totalSteps, 'Synthesis complete');
140
+ }
141
+
112
142
  const result: DebateResult = {
113
143
  topic: prompt,
114
144
  format,
@@ -1,6 +1,7 @@
1
1
  import { ProviderManager } from '../providers/manager.js';
2
2
  import { IterationRound, IterationResult } from '../config/types.js';
3
3
  import { logger } from '../utils/logger.js';
4
+ import type { ProgressReporter } from '../services/progress.js';
4
5
 
5
6
  export interface DuckIterateArgs {
6
7
  prompt: string;
@@ -14,7 +15,9 @@ const CONVERGENCE_THRESHOLD = 0.8; // 80% similarity indicates convergence
14
15
 
15
16
  export async function duckIterateTool(
16
17
  providerManager: ProviderManager,
17
- args: Record<string, unknown>
18
+ args: Record<string, unknown>,
19
+ progress?: ProgressReporter,
20
+ signal?: AbortSignal
18
21
  ) {
19
22
  const {
20
23
  prompt,
@@ -54,6 +57,10 @@ export async function duckIterateTool(
54
57
  let lastResponse = '';
55
58
  let converged = false;
56
59
 
60
+ if (signal?.aborted) {
61
+ throw new Error('Task cancelled');
62
+ }
63
+
57
64
  // Round 1: Initial generation by provider A
58
65
  const initialResponse = await providerManager.askDuck(providers[0], prompt);
59
66
  const providerAInfo = providerManager.getProvider(providers[0]);
@@ -70,8 +77,16 @@ export async function duckIterateTool(
70
77
  lastResponse = initialResponse.content;
71
78
  logger.info(`Round 1: ${providers[0]} generated initial response`);
72
79
 
80
+ if (progress) {
81
+ void progress.report(1, iterations, `Round 1/${iterations}: ${providers[0]} generated`);
82
+ }
83
+
73
84
  // Subsequent rounds: Alternate between providers
74
85
  for (let i = 2; i <= iterations; i++) {
86
+ if (signal?.aborted) {
87
+ throw new Error('Task cancelled');
88
+ }
89
+
75
90
  const isProviderA = i % 2 === 1;
76
91
  const currentProvider = isProviderA ? providers[0] : providers[1];
77
92
  const providerInfo = providerManager.getProvider(currentProvider);
@@ -100,6 +115,10 @@ export async function duckIterateTool(
100
115
  lastResponse = response.content;
101
116
  logger.info(`Round ${i}: ${currentProvider} ${role === 'critic' ? 'critiqued' : 'refined'}`);
102
117
 
118
+ if (progress) {
119
+ void progress.report(i, iterations, `Round ${i}/${iterations}: ${currentProvider} ${role}`);
120
+ }
121
+
103
122
  if (converged) {
104
123
  break;
105
124
  }
@@ -2,6 +2,7 @@ import { ProviderManager } from '../providers/manager.js';
2
2
  import { ConsensusService } from '../services/consensus.js';
3
3
  import { VoteResult } from '../config/types.js';
4
4
  import { logger } from '../utils/logger.js';
5
+ import type { ProgressReporter } from '../services/progress.js';
5
6
 
6
7
  export interface DuckVoteArgs {
7
8
  question: string;
@@ -12,7 +13,8 @@ export interface DuckVoteArgs {
12
13
 
13
14
  export async function duckVoteTool(
14
15
  providerManager: ProviderManager,
15
- args: Record<string, unknown>
16
+ args: Record<string, unknown>,
17
+ progress?: ProgressReporter
16
18
  ) {
17
19
  const {
18
20
  question,
@@ -52,8 +54,17 @@ export async function duckVoteTool(
52
54
  require_reasoning
53
55
  );
54
56
 
55
- // Get votes from all ducks in parallel
56
- const responses = await providerManager.compareDucks(votePrompt, voterNames);
57
+ // Get votes from all ducks in parallel, reporting progress as each votes
58
+ const responses = progress
59
+ ? await providerManager.compareDucksWithProgress(
60
+ votePrompt,
61
+ voterNames,
62
+ undefined,
63
+ (providerName, completed, total) => {
64
+ void progress.report(completed, total, `${providerName} voted (${completed}/${total})`);
65
+ }
66
+ )
67
+ : await providerManager.compareDucks(votePrompt, voterNames);
57
68
 
58
69
  // Parse votes
59
70
  const votes: VoteResult[] = responses.map(response => {
@@ -389,4 +389,84 @@ describe('duckDebateTool', () => {
389
389
  // Should not contain the full 900 A's
390
390
  expect(text).not.toContain('A'.repeat(900));
391
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
+ });
392
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
  });
@@ -266,6 +266,76 @@ describe('duckVoteTool', () => {
266
266
  mockProviderManager.getProviderNames = originalGetProviderNames;
267
267
  });
268
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
+
269
339
  it('should handle case when no valid votes result in no winner', async () => {
270
340
  // Both responses don't mention any valid option
271
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 = {