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,344 @@
1
+ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
2
+
3
+ jest.mock('../../src/utils/logger');
4
+
5
+ import { TaskManager } from '../../src/services/task-manager.js';
6
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
7
+
8
+ describe('TaskManager', () => {
9
+ let taskManager: TaskManager;
10
+
11
+ beforeEach(() => {
12
+ taskManager = new TaskManager({
13
+ cleanupInterval: 600_000, // long interval to avoid noise
14
+ });
15
+ });
16
+
17
+ afterEach(() => {
18
+ taskManager.shutdown();
19
+ });
20
+
21
+ describe('constructor', () => {
22
+ it('should use default config values when none provided', () => {
23
+ const tm = new TaskManager();
24
+ try {
25
+ expect(tm.config.defaultTtl).toBe(300_000);
26
+ expect(tm.config.pollInterval).toBe(2_000);
27
+ expect(tm.config.maxQueueSize).toBe(100);
28
+ expect(tm.config.cleanupInterval).toBe(60_000);
29
+ } finally {
30
+ tm.shutdown();
31
+ }
32
+ });
33
+
34
+ it('should allow partial config overrides', () => {
35
+ const tm = new TaskManager({ defaultTtl: 60_000 });
36
+ try {
37
+ expect(tm.config.defaultTtl).toBe(60_000);
38
+ expect(tm.config.pollInterval).toBe(2_000); // default preserved
39
+ } finally {
40
+ tm.shutdown();
41
+ }
42
+ });
43
+
44
+ it('should provide a task store and message queue', () => {
45
+ expect(taskManager.taskStore).toBeDefined();
46
+ expect(taskManager.taskMessageQueue).toBeDefined();
47
+ });
48
+ });
49
+
50
+ describe('startBackground', () => {
51
+ it('should execute work and store a completed result', async () => {
52
+ // Create a task first (need requestId and request)
53
+ const task = await taskManager.taskStore.createTask(
54
+ { ttl: 60_000 },
55
+ 'req-1',
56
+ { method: 'tools/call', params: { name: 'test', arguments: {} } }
57
+ );
58
+
59
+ const expectedResult: CallToolResult = {
60
+ content: [{ type: 'text', text: 'done!' }],
61
+ };
62
+
63
+ taskManager.startBackground(task.taskId, async () => {
64
+ return expectedResult;
65
+ });
66
+
67
+ // Wait for the background work to complete
68
+ await new Promise(resolve => setTimeout(resolve, 50));
69
+
70
+ const storedTask = await taskManager.taskStore.getTask(task.taskId);
71
+ expect(storedTask?.status).toBe('completed');
72
+
73
+ const result = await taskManager.taskStore.getTaskResult(task.taskId);
74
+ expect(result).toEqual(expectedResult);
75
+ });
76
+
77
+ it('should store a failed result when work throws', async () => {
78
+ const task = await taskManager.taskStore.createTask(
79
+ { ttl: 60_000 },
80
+ 'req-2',
81
+ { method: 'tools/call', params: { name: 'test', arguments: {} } }
82
+ );
83
+
84
+ taskManager.startBackground(task.taskId, async () => {
85
+ throw new Error('Something went wrong');
86
+ });
87
+
88
+ await new Promise(resolve => setTimeout(resolve, 50));
89
+
90
+ const storedTask = await taskManager.taskStore.getTask(task.taskId);
91
+ expect(storedTask?.status).toBe('failed');
92
+
93
+ const result = await taskManager.taskStore.getTaskResult(task.taskId);
94
+ expect((result as CallToolResult).isError).toBe(true);
95
+ expect((result as CallToolResult).content[0]).toEqual(
96
+ expect.objectContaining({ type: 'text', text: expect.stringContaining('Something went wrong') })
97
+ );
98
+ });
99
+
100
+ it('should mark task as cancelled when work is aborted', async () => {
101
+ const task = await taskManager.taskStore.createTask(
102
+ { ttl: 60_000 },
103
+ 'req-3',
104
+ { method: 'tools/call', params: { name: 'test', arguments: {} } }
105
+ );
106
+
107
+ taskManager.startBackground(task.taskId, async (signal) => {
108
+ // Simulate long-running work that checks the signal
109
+ return new Promise<CallToolResult>((resolve, reject) => {
110
+ const interval = setInterval(() => {
111
+ if (signal.aborted) {
112
+ clearInterval(interval);
113
+ reject(new Error('aborted'));
114
+ }
115
+ }, 10);
116
+ });
117
+ });
118
+
119
+ // Give it a moment to start
120
+ await new Promise(resolve => setTimeout(resolve, 20));
121
+
122
+ // Cancel it
123
+ const cancelled = taskManager.cancel(task.taskId);
124
+ expect(cancelled).toBe(true);
125
+
126
+ await new Promise(resolve => setTimeout(resolve, 50));
127
+
128
+ const storedTask = await taskManager.taskStore.getTask(task.taskId);
129
+ expect(storedTask?.status).toBe('cancelled');
130
+ });
131
+
132
+ it('should mark task as cancelled when work succeeds but signal was already aborted', async () => {
133
+ const task = await taskManager.taskStore.createTask(
134
+ { ttl: 60_000 },
135
+ 'req-abort-success',
136
+ { method: 'tools/call', params: { name: 'test', arguments: {} } }
137
+ );
138
+
139
+ // Simulate: work succeeds, but the abort signal fires during the last step.
140
+ // The work function returns normally (no throw), yet signal.aborted is true.
141
+ taskManager.startBackground(task.taskId, async (signal) => {
142
+ // Pretend we're doing multi-step work; abort happens during the last step
143
+ // but the function still returns a result instead of throwing.
144
+ const controller = (taskManager as any).activeControllers.get(task.taskId) as AbortController;
145
+ controller.abort(); // abort mid-execution
146
+ // Return successfully despite abort
147
+ return { content: [{ type: 'text', text: 'completed despite abort' }] };
148
+ });
149
+
150
+ await new Promise(resolve => setTimeout(resolve, 50));
151
+
152
+ const storedTask = await taskManager.taskStore.getTask(task.taskId);
153
+ // Should be cancelled, NOT completed or stuck in 'working'
154
+ expect(storedTask?.status).toBe('cancelled');
155
+ });
156
+
157
+ it('should clean up the AbortController after work completes', async () => {
158
+ const task = await taskManager.taskStore.createTask(
159
+ { ttl: 60_000 },
160
+ 'req-4',
161
+ { method: 'tools/call', params: { name: 'test', arguments: {} } }
162
+ );
163
+
164
+ taskManager.startBackground(task.taskId, async () => {
165
+ return { content: [{ type: 'text', text: 'ok' }] };
166
+ });
167
+
168
+ await new Promise(resolve => setTimeout(resolve, 50));
169
+
170
+ expect(taskManager.activeCount).toBe(0);
171
+ });
172
+ });
173
+
174
+ describe('cancel', () => {
175
+ it('should return false if the task is not active', () => {
176
+ expect(taskManager.cancel('nonexistent')).toBe(false);
177
+ });
178
+
179
+ it('should return true and abort the signal for an active task', async () => {
180
+ const task = await taskManager.taskStore.createTask(
181
+ { ttl: 60_000 },
182
+ 'req-5',
183
+ { method: 'tools/call', params: { name: 'test', arguments: {} } }
184
+ );
185
+
186
+ let signalAborted = false;
187
+ taskManager.startBackground(task.taskId, async (signal) => {
188
+ return new Promise<CallToolResult>((resolve, reject) => {
189
+ signal.addEventListener('abort', () => {
190
+ signalAborted = true;
191
+ reject(new Error('aborted'));
192
+ });
193
+ });
194
+ });
195
+
196
+ await new Promise(resolve => setTimeout(resolve, 20));
197
+ expect(taskManager.cancel(task.taskId)).toBe(true);
198
+
199
+ await new Promise(resolve => setTimeout(resolve, 50));
200
+ expect(signalAborted).toBe(true);
201
+ });
202
+ });
203
+
204
+ describe('activeCount', () => {
205
+ it('should track the number of running tasks', async () => {
206
+ expect(taskManager.activeCount).toBe(0);
207
+
208
+ const task1 = await taskManager.taskStore.createTask(
209
+ { ttl: 60_000 },
210
+ 'req-a',
211
+ { method: 'tools/call', params: { name: 'test', arguments: {} } }
212
+ );
213
+ const task2 = await taskManager.taskStore.createTask(
214
+ { ttl: 60_000 },
215
+ 'req-b',
216
+ { method: 'tools/call', params: { name: 'test', arguments: {} } }
217
+ );
218
+
219
+ let resolve1: () => void;
220
+ let resolve2: () => void;
221
+ const p1 = new Promise<void>(r => { resolve1 = r; });
222
+ const p2 = new Promise<void>(r => { resolve2 = r; });
223
+
224
+ taskManager.startBackground(task1.taskId, async () => {
225
+ await p1;
226
+ return { content: [{ type: 'text', text: '1' }] };
227
+ });
228
+ taskManager.startBackground(task2.taskId, async () => {
229
+ await p2;
230
+ return { content: [{ type: 'text', text: '2' }] };
231
+ });
232
+
233
+ await new Promise(resolve => setTimeout(resolve, 20));
234
+ expect(taskManager.activeCount).toBe(2);
235
+
236
+ resolve1!();
237
+ await new Promise(resolve => setTimeout(resolve, 50));
238
+ expect(taskManager.activeCount).toBe(1);
239
+
240
+ resolve2!();
241
+ await new Promise(resolve => setTimeout(resolve, 50));
242
+ expect(taskManager.activeCount).toBe(0);
243
+ });
244
+ });
245
+
246
+ describe('shutdown', () => {
247
+ it('should cancel all active tasks', async () => {
248
+ const task = await taskManager.taskStore.createTask(
249
+ { ttl: 60_000 },
250
+ 'req-s',
251
+ { method: 'tools/call', params: { name: 'test', arguments: {} } }
252
+ );
253
+
254
+ let aborted = false;
255
+ taskManager.startBackground(task.taskId, async (signal) => {
256
+ return new Promise<CallToolResult>((resolve, reject) => {
257
+ signal.addEventListener('abort', () => {
258
+ aborted = true;
259
+ reject(new Error('aborted'));
260
+ });
261
+ });
262
+ });
263
+
264
+ await new Promise(resolve => setTimeout(resolve, 20));
265
+ expect(taskManager.activeCount).toBe(1);
266
+
267
+ taskManager.shutdown();
268
+
269
+ await new Promise(resolve => setTimeout(resolve, 50));
270
+ expect(aborted).toBe(true);
271
+ expect(taskManager.activeCount).toBe(0);
272
+ });
273
+
274
+ it('should be safe to call multiple times', () => {
275
+ taskManager.shutdown();
276
+ taskManager.shutdown();
277
+ // No error thrown
278
+ });
279
+ });
280
+
281
+ describe('TTL edge cases', () => {
282
+ it('should handle task TTL expiry during background work without crashing', async () => {
283
+ // Use a very short TTL so the task entry is cleaned up before work completes
284
+ const shortTtlManager = new TaskManager({
285
+ cleanupInterval: 600_000,
286
+ });
287
+
288
+ try {
289
+ const task = await shortTtlManager.taskStore.createTask(
290
+ { ttl: 50 }, // 50ms TTL — very short
291
+ 'req-ttl',
292
+ { method: 'tools/call', params: { name: 'test', arguments: {} } }
293
+ );
294
+
295
+ shortTtlManager.startBackground(task.taskId, async () => {
296
+ // Simulate slow work that exceeds the TTL
297
+ await new Promise(resolve => setTimeout(resolve, 150));
298
+ return { content: [{ type: 'text', text: 'finished after TTL' }] };
299
+ });
300
+
301
+ // Wait for both TTL cleanup and work to complete
302
+ await new Promise(resolve => setTimeout(resolve, 300));
303
+
304
+ // The task entry should have been cleaned up by TTL
305
+ const storedTask = await shortTtlManager.taskStore.getTask(task.taskId);
306
+ expect(storedTask).toBeNull();
307
+
308
+ // Critically: no uncaught errors should have occurred.
309
+ // The startBackground catch handler gracefully handles "Task not found" errors
310
+ // from storeTaskResult when the TTL has already cleaned up the entry.
311
+ expect(shortTtlManager.activeCount).toBe(0);
312
+ } finally {
313
+ shortTtlManager.shutdown();
314
+ }
315
+ });
316
+
317
+ it('should handle task TTL expiry during failed background work without crashing', async () => {
318
+ const shortTtlManager = new TaskManager({
319
+ cleanupInterval: 600_000,
320
+ });
321
+
322
+ try {
323
+ const task = await shortTtlManager.taskStore.createTask(
324
+ { ttl: 50 },
325
+ 'req-ttl-fail',
326
+ { method: 'tools/call', params: { name: 'test', arguments: {} } }
327
+ );
328
+
329
+ shortTtlManager.startBackground(task.taskId, async () => {
330
+ await new Promise(resolve => setTimeout(resolve, 150));
331
+ throw new Error('Work failed after TTL expired');
332
+ });
333
+
334
+ await new Promise(resolve => setTimeout(resolve, 300));
335
+
336
+ const storedTask = await shortTtlManager.taskStore.getTask(task.taskId);
337
+ expect(storedTask).toBeNull();
338
+ expect(shortTtlManager.activeCount).toBe(0);
339
+ } finally {
340
+ shortTtlManager.shutdown();
341
+ }
342
+ });
343
+ });
344
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2
+ import { compareDucksTool } from '../../src/tools/compare-ducks.js';
3
+ import { ProviderManager } from '../../src/providers/manager.js';
4
+
5
+ // Mock dependencies
6
+ jest.mock('../../src/utils/logger');
7
+ jest.mock('../../src/providers/manager.js');
8
+
9
+ describe('compareDucksTool structured JSON', () => {
10
+ let mockProviderManager: jest.Mocked<ProviderManager>;
11
+
12
+ const mockResponses = [
13
+ {
14
+ provider: 'openai',
15
+ nickname: 'OpenAI Duck',
16
+ content: 'TypeScript is great!',
17
+ model: 'gpt-4',
18
+ latency: 150,
19
+ cached: false,
20
+ usage: {
21
+ prompt_tokens: 10,
22
+ completion_tokens: 20,
23
+ total_tokens: 30,
24
+ },
25
+ },
26
+ {
27
+ provider: 'groq',
28
+ nickname: 'Groq Duck',
29
+ content: 'TypeScript rocks!',
30
+ model: 'llama-3.1-70b',
31
+ latency: 80,
32
+ cached: true,
33
+ usage: {
34
+ prompt_tokens: 10,
35
+ completion_tokens: 15,
36
+ total_tokens: 25,
37
+ },
38
+ },
39
+ ];
40
+
41
+ beforeEach(() => {
42
+ mockProviderManager = {
43
+ compareDucks: jest.fn().mockResolvedValue(mockResponses),
44
+ } as unknown as jest.Mocked<ProviderManager>;
45
+ });
46
+
47
+ it('should return two content items: text and JSON', async () => {
48
+ const result = await compareDucksTool(mockProviderManager, { prompt: 'Test' });
49
+
50
+ expect(result.content).toHaveLength(2);
51
+ expect(result.content[0].type).toBe('text');
52
+ expect(result.content[1].type).toBe('text');
53
+ });
54
+
55
+ it('should have valid JSON in the second content item', async () => {
56
+ const result = await compareDucksTool(mockProviderManager, { prompt: 'Test' });
57
+
58
+ const data = JSON.parse(result.content[1].text) as unknown[];
59
+ expect(Array.isArray(data)).toBe(true);
60
+ });
61
+
62
+ it('should include all provider data in JSON', async () => {
63
+ const result = await compareDucksTool(mockProviderManager, { prompt: 'Test' });
64
+
65
+ const data = JSON.parse(result.content[1].text) as {
66
+ provider: string;
67
+ nickname: string;
68
+ model: string;
69
+ content: string;
70
+ latency: number;
71
+ tokens: { prompt: number; completion: number; total: number } | null;
72
+ cached: boolean;
73
+ error?: string;
74
+ }[];
75
+
76
+ expect(data).toHaveLength(2);
77
+ expect(data[0].provider).toBe('openai');
78
+ expect(data[0].nickname).toBe('OpenAI Duck');
79
+ expect(data[0].model).toBe('gpt-4');
80
+ expect(data[0].content).toBe('TypeScript is great!');
81
+ expect(data[0].latency).toBe(150);
82
+ expect(data[0].tokens).toEqual({ prompt: 10, completion: 20, total: 30 });
83
+ expect(data[0].cached).toBe(false);
84
+
85
+ expect(data[1].provider).toBe('groq');
86
+ expect(data[1].cached).toBe(true);
87
+ });
88
+
89
+ it('should include error info for failed responses', async () => {
90
+ mockProviderManager.compareDucks.mockResolvedValue([
91
+ mockResponses[0],
92
+ {
93
+ provider: 'groq',
94
+ nickname: 'Groq Duck',
95
+ content: 'Error: API key invalid',
96
+ model: '',
97
+ latency: 0,
98
+ cached: false,
99
+ },
100
+ ]);
101
+
102
+ const result = await compareDucksTool(mockProviderManager, { prompt: 'Test' });
103
+ const data = JSON.parse(result.content[1].text) as { error?: string }[];
104
+
105
+ expect(data[0].error).toBeUndefined();
106
+ expect(data[1].error).toBe('Error: API key invalid');
107
+ });
108
+
109
+ it('should handle null tokens when usage is missing', async () => {
110
+ mockProviderManager.compareDucks.mockResolvedValue([
111
+ {
112
+ provider: 'openai',
113
+ nickname: 'OpenAI Duck',
114
+ content: 'Response',
115
+ model: 'gpt-4',
116
+ latency: 100,
117
+ cached: false,
118
+ },
119
+ ]);
120
+
121
+ const result = await compareDucksTool(mockProviderManager, { prompt: 'Test' });
122
+ const data = JSON.parse(result.content[1].text) as { tokens: unknown }[];
123
+
124
+ expect(data[0].tokens).toBeNull();
125
+ });
126
+
127
+ it('should preserve text content identical to before', async () => {
128
+ const result = await compareDucksTool(mockProviderManager, { prompt: 'Test' });
129
+
130
+ // First item is text, should contain original format
131
+ expect(result.content[0].text).toContain('OpenAI Duck');
132
+ expect(result.content[0].text).toContain('Groq Duck');
133
+ expect(result.content[0].text).toContain('2/2 ducks responded successfully');
134
+ });
135
+ });
@@ -58,10 +58,12 @@ describe('compareDucksTool', () => {
58
58
  undefined,
59
59
  { model: undefined }
60
60
  );
61
- expect(result.content).toHaveLength(1);
61
+ expect(result.content).toHaveLength(2);
62
62
  expect(result.content[0].type).toBe('text');
63
63
  expect(result.content[0].text).toContain('Asked:');
64
64
  expect(result.content[0].text).toContain('What is TypeScript?');
65
+ expect(result.content[1].type).toBe('text');
66
+ expect(() => JSON.parse(result.content[1].text)).not.toThrow();
65
67
  });
66
68
 
67
69
  it('should display all duck responses', async () => {
@@ -187,4 +189,23 @@ describe('compareDucksTool', () => {
187
189
  // (the code checks latency > 0)
188
190
  expect(result.content[0].text).not.toContain('0ms');
189
191
  });
192
+
193
+ it('should use compareDucksWithProgress when progress is provided', async () => {
194
+ mockProviderManager.compareDucksWithProgress = jest.fn().mockResolvedValue(mockResponses) as any;
195
+ const mockProgress = {
196
+ enabled: true,
197
+ report: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
198
+ };
199
+
200
+ await compareDucksTool(mockProviderManager, { prompt: 'Test' }, mockProgress);
201
+
202
+ expect(mockProviderManager.compareDucksWithProgress).toHaveBeenCalled();
203
+ expect(mockProviderManager.compareDucks).not.toHaveBeenCalled();
204
+ });
205
+
206
+ it('should use compareDucks when no progress is provided', async () => {
207
+ await compareDucksTool(mockProviderManager, { prompt: 'Test' });
208
+
209
+ expect(mockProviderManager.compareDucks).toHaveBeenCalled();
210
+ });
190
211
  });
@@ -190,6 +190,25 @@ describe('duckCouncilTool', () => {
190
190
  expect(result.content[0].text).not.toContain('tokens');
191
191
  });
192
192
 
193
+ it('should use compareDucksWithProgress when progress is provided', async () => {
194
+ mockProviderManager.compareDucksWithProgress = jest.fn().mockResolvedValue(mockResponses) as any;
195
+ const mockProgress = {
196
+ enabled: true,
197
+ report: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
198
+ };
199
+
200
+ await duckCouncilTool(mockProviderManager, { prompt: 'Test' }, mockProgress);
201
+
202
+ expect(mockProviderManager.compareDucksWithProgress).toHaveBeenCalled();
203
+ expect(mockProviderManager.duckCouncil).not.toHaveBeenCalled();
204
+ });
205
+
206
+ it('should use duckCouncil when no progress is provided', async () => {
207
+ await duckCouncilTool(mockProviderManager, { prompt: 'Test' });
208
+
209
+ expect(mockProviderManager.duckCouncil).toHaveBeenCalled();
210
+ });
211
+
193
212
  it('should show error message when all ducks fail', async () => {
194
213
  mockProviderManager.duckCouncil.mockResolvedValue([
195
214
  {