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.
- package/CHANGELOG.md +7 -0
- package/README.md +8 -0
- 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 +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +93 -33
- 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 +7 -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 +19 -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 +7 -3
- package/dist/tools/duck-vote.js.map +1 -1
- package/package.json +1 -1
- package/src/providers/enhanced-manager.ts +49 -0
- package/src/providers/manager.ts +45 -0
- package/src/server.ts +110 -32
- package/src/services/progress.ts +59 -0
- package/src/services/task-manager.ts +162 -0
- package/src/tools/compare-ducks.ts +14 -3
- package/src/tools/duck-council.ts +15 -4
- package/src/tools/duck-debate.ts +31 -1
- package/src/tools/duck-iterate.ts +20 -1
- package/src/tools/duck-vote.ts +14 -3
- package/tests/duck-debate.test.ts +80 -0
- package/tests/duck-iterate.test.ts +81 -0
- package/tests/duck-vote.test.ts +70 -0
- 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.test.ts +19 -0
- 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 =
|
|
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`;
|
package/src/tools/duck-debate.ts
CHANGED
|
@@ -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
|
}
|
package/src/tools/duck-vote.ts
CHANGED
|
@@ -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 =
|
|
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
|
});
|
package/tests/duck-vote.test.ts
CHANGED
|
@@ -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
|
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 = {
|