mcp-rubber-duck 1.5.1 → 1.6.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 (69) hide show
  1. package/.claude/agents/pricing-updater.md +111 -0
  2. package/.claude/commands/update-pricing.md +22 -0
  3. package/.releaserc.json +4 -0
  4. package/CHANGELOG.md +14 -0
  5. package/dist/config/types.d.ts +72 -0
  6. package/dist/config/types.d.ts.map +1 -1
  7. package/dist/config/types.js +8 -0
  8. package/dist/config/types.js.map +1 -1
  9. package/dist/data/default-pricing.d.ts +18 -0
  10. package/dist/data/default-pricing.d.ts.map +1 -0
  11. package/dist/data/default-pricing.js +307 -0
  12. package/dist/data/default-pricing.js.map +1 -0
  13. package/dist/providers/enhanced-manager.d.ts +2 -1
  14. package/dist/providers/enhanced-manager.d.ts.map +1 -1
  15. package/dist/providers/enhanced-manager.js +20 -2
  16. package/dist/providers/enhanced-manager.js.map +1 -1
  17. package/dist/providers/manager.d.ts +3 -1
  18. package/dist/providers/manager.d.ts.map +1 -1
  19. package/dist/providers/manager.js +12 -1
  20. package/dist/providers/manager.js.map +1 -1
  21. package/dist/server.d.ts +2 -0
  22. package/dist/server.d.ts.map +1 -1
  23. package/dist/server.js +35 -4
  24. package/dist/server.js.map +1 -1
  25. package/dist/services/pricing.d.ts +56 -0
  26. package/dist/services/pricing.d.ts.map +1 -0
  27. package/dist/services/pricing.js +124 -0
  28. package/dist/services/pricing.js.map +1 -0
  29. package/dist/services/usage.d.ts +48 -0
  30. package/dist/services/usage.d.ts.map +1 -0
  31. package/dist/services/usage.js +243 -0
  32. package/dist/services/usage.js.map +1 -0
  33. package/dist/tools/get-usage-stats.d.ts +8 -0
  34. package/dist/tools/get-usage-stats.d.ts.map +1 -0
  35. package/dist/tools/get-usage-stats.js +92 -0
  36. package/dist/tools/get-usage-stats.js.map +1 -0
  37. package/package.json +1 -1
  38. package/src/config/types.ts +51 -0
  39. package/src/data/default-pricing.ts +368 -0
  40. package/src/providers/enhanced-manager.ts +41 -4
  41. package/src/providers/manager.ts +22 -1
  42. package/src/server.ts +42 -4
  43. package/src/services/pricing.ts +155 -0
  44. package/src/services/usage.ts +293 -0
  45. package/src/tools/get-usage-stats.ts +109 -0
  46. package/tests/approval.test.ts +440 -0
  47. package/tests/cache.test.ts +240 -0
  48. package/tests/config.test.ts +468 -0
  49. package/tests/consensus.test.ts +10 -0
  50. package/tests/conversation.test.ts +86 -0
  51. package/tests/duck-debate.test.ts +105 -1
  52. package/tests/duck-iterate.test.ts +30 -0
  53. package/tests/duck-judge.test.ts +93 -0
  54. package/tests/duck-vote.test.ts +46 -0
  55. package/tests/health.test.ts +129 -0
  56. package/tests/pricing.test.ts +335 -0
  57. package/tests/providers.test.ts +591 -0
  58. package/tests/safe-logger.test.ts +314 -0
  59. package/tests/tools/approve-mcp-request.test.ts +239 -0
  60. package/tests/tools/ask-duck.test.ts +159 -0
  61. package/tests/tools/chat-duck.test.ts +191 -0
  62. package/tests/tools/compare-ducks.test.ts +190 -0
  63. package/tests/tools/duck-council.test.ts +219 -0
  64. package/tests/tools/get-pending-approvals.test.ts +195 -0
  65. package/tests/tools/get-usage-stats.test.ts +236 -0
  66. package/tests/tools/list-ducks.test.ts +144 -0
  67. package/tests/tools/list-models.test.ts +163 -0
  68. package/tests/tools/mcp-status.test.ts +330 -0
  69. package/tests/usage.test.ts +661 -0
@@ -4,6 +4,474 @@ import { ConfigManager } from '../src/config/config';
4
4
  // Mock logger to avoid console noise during tests
5
5
  jest.mock('../src/utils/logger');
6
6
 
7
+ describe('ConfigManager - Default Providers', () => {
8
+ let originalEnv: NodeJS.ProcessEnv;
9
+
10
+ beforeEach(() => {
11
+ originalEnv = { ...process.env };
12
+ jest.clearAllMocks();
13
+ // Clear all provider keys
14
+ delete process.env.OPENAI_API_KEY;
15
+ delete process.env.GEMINI_API_KEY;
16
+ delete process.env.GROQ_API_KEY;
17
+ delete process.env.OLLAMA_BASE_URL;
18
+ delete process.env.ENABLE_OLLAMA;
19
+ Object.keys(process.env).forEach(key => {
20
+ if (key.startsWith('CUSTOM_')) delete process.env[key];
21
+ });
22
+ });
23
+
24
+ afterEach(() => {
25
+ process.env = originalEnv;
26
+ });
27
+
28
+ it('should create OpenAI provider when API key is set', () => {
29
+ process.env.OPENAI_API_KEY = 'sk-test-key';
30
+
31
+ const configManager = new ConfigManager();
32
+ const providers = configManager.getAllProviders();
33
+
34
+ expect(providers.openai).toBeDefined();
35
+ expect(providers.openai.api_key).toBe('sk-test-key');
36
+ expect(providers.openai.base_url).toBe('https://api.openai.com/v1');
37
+ expect(providers.openai.nickname).toBe('GPT Duck');
38
+ });
39
+
40
+ it('should use custom OpenAI model and nickname from env', () => {
41
+ process.env.OPENAI_API_KEY = 'sk-test-key';
42
+ process.env.OPENAI_DEFAULT_MODEL = 'gpt-4-turbo';
43
+ process.env.OPENAI_NICKNAME = 'Custom GPT Duck';
44
+
45
+ const configManager = new ConfigManager();
46
+ const providers = configManager.getAllProviders();
47
+
48
+ expect(providers.openai.default_model).toBe('gpt-4-turbo');
49
+ expect(providers.openai.nickname).toBe('Custom GPT Duck');
50
+ });
51
+
52
+ it('should create Gemini provider when API key is set', () => {
53
+ process.env.GEMINI_API_KEY = 'gemini-test-key';
54
+
55
+ const configManager = new ConfigManager();
56
+ const providers = configManager.getAllProviders();
57
+
58
+ expect(providers.gemini).toBeDefined();
59
+ expect(providers.gemini.api_key).toBe('gemini-test-key');
60
+ expect(providers.gemini.nickname).toBe('Gemini Duck');
61
+ });
62
+
63
+ it('should use custom Gemini model and nickname from env', () => {
64
+ process.env.GEMINI_API_KEY = 'gemini-test-key';
65
+ process.env.GEMINI_DEFAULT_MODEL = 'gemini-pro';
66
+ process.env.GEMINI_NICKNAME = 'Custom Gemini Duck';
67
+
68
+ const configManager = new ConfigManager();
69
+ const providers = configManager.getAllProviders();
70
+
71
+ expect(providers.gemini.default_model).toBe('gemini-pro');
72
+ expect(providers.gemini.nickname).toBe('Custom Gemini Duck');
73
+ });
74
+
75
+ it('should create Groq provider when API key is set', () => {
76
+ process.env.GROQ_API_KEY = 'gsk-test-key';
77
+
78
+ const configManager = new ConfigManager();
79
+ const providers = configManager.getAllProviders();
80
+
81
+ expect(providers.groq).toBeDefined();
82
+ expect(providers.groq.api_key).toBe('gsk-test-key');
83
+ expect(providers.groq.nickname).toBe('Groq Duck');
84
+ });
85
+
86
+ it('should use custom Groq model and nickname from env', () => {
87
+ process.env.GROQ_API_KEY = 'gsk-test-key';
88
+ process.env.GROQ_DEFAULT_MODEL = 'custom-groq-model';
89
+ process.env.GROQ_NICKNAME = 'Fast Groq Duck';
90
+
91
+ const configManager = new ConfigManager();
92
+ const providers = configManager.getAllProviders();
93
+
94
+ expect(providers.groq.default_model).toBe('custom-groq-model');
95
+ expect(providers.groq.nickname).toBe('Fast Groq Duck');
96
+ });
97
+
98
+ it('should create Ollama provider when OLLAMA_BASE_URL is set', () => {
99
+ process.env.OPENAI_API_KEY = 'dummy'; // Need at least one provider
100
+ process.env.OLLAMA_BASE_URL = 'http://localhost:11434/v1';
101
+
102
+ const configManager = new ConfigManager();
103
+ const providers = configManager.getAllProviders();
104
+
105
+ expect(providers.ollama).toBeDefined();
106
+ expect(providers.ollama.api_key).toBe('not-needed');
107
+ expect(providers.ollama.base_url).toBe('http://localhost:11434/v1');
108
+ expect(providers.ollama.nickname).toBe('Local Duck');
109
+ });
110
+
111
+ it('should create Ollama provider when ENABLE_OLLAMA is true', () => {
112
+ process.env.OPENAI_API_KEY = 'dummy'; // Need at least one provider
113
+ process.env.ENABLE_OLLAMA = 'true';
114
+
115
+ const configManager = new ConfigManager();
116
+ const providers = configManager.getAllProviders();
117
+
118
+ expect(providers.ollama).toBeDefined();
119
+ expect(providers.ollama.base_url).toBe('http://localhost:11434/v1');
120
+ });
121
+
122
+ it('should use custom Ollama model and nickname from env', () => {
123
+ process.env.OPENAI_API_KEY = 'dummy';
124
+ process.env.ENABLE_OLLAMA = 'true';
125
+ process.env.OLLAMA_DEFAULT_MODEL = 'mistral';
126
+ process.env.OLLAMA_NICKNAME = 'My Local Duck';
127
+
128
+ const configManager = new ConfigManager();
129
+ const providers = configManager.getAllProviders();
130
+
131
+ expect(providers.ollama.default_model).toBe('mistral');
132
+ expect(providers.ollama.nickname).toBe('My Local Duck');
133
+ });
134
+ });
135
+
136
+ describe('ConfigManager - MCP Bridge Config', () => {
137
+ let originalEnv: NodeJS.ProcessEnv;
138
+
139
+ beforeEach(() => {
140
+ originalEnv = { ...process.env };
141
+ jest.clearAllMocks();
142
+ // Clear MCP-related env vars
143
+ Object.keys(process.env).forEach(key => {
144
+ if (key.startsWith('MCP_')) delete process.env[key];
145
+ if (key.startsWith('CUSTOM_')) delete process.env[key];
146
+ });
147
+ delete process.env.OPENAI_API_KEY;
148
+ });
149
+
150
+ afterEach(() => {
151
+ process.env = originalEnv;
152
+ });
153
+
154
+ it('should enable MCP bridge when MCP_BRIDGE_ENABLED=true', () => {
155
+ process.env.OPENAI_API_KEY = 'test-key';
156
+ process.env.MCP_BRIDGE_ENABLED = 'true';
157
+
158
+ const configManager = new ConfigManager();
159
+ expect(configManager.getConfig().mcp_bridge?.enabled).toBe(true);
160
+ });
161
+
162
+ it('should disable MCP bridge when MCP_BRIDGE_ENABLED=false', () => {
163
+ process.env.OPENAI_API_KEY = 'test-key';
164
+ process.env.MCP_BRIDGE_ENABLED = 'false';
165
+
166
+ const configManager = new ConfigManager();
167
+ expect(configManager.getConfig().mcp_bridge?.enabled).toBe(false);
168
+ });
169
+
170
+ it('should set approval mode from environment', () => {
171
+ process.env.OPENAI_API_KEY = 'test-key';
172
+ process.env.MCP_BRIDGE_ENABLED = 'true';
173
+ process.env.MCP_APPROVAL_MODE = 'always';
174
+
175
+ const configManager = new ConfigManager();
176
+ expect(configManager.getConfig().mcp_bridge?.approval_mode).toBe('always');
177
+ });
178
+
179
+ it('should set approval timeout from environment', () => {
180
+ process.env.OPENAI_API_KEY = 'test-key';
181
+ process.env.MCP_BRIDGE_ENABLED = 'true';
182
+ process.env.MCP_APPROVAL_TIMEOUT = '120';
183
+
184
+ const configManager = new ConfigManager();
185
+ expect(configManager.getConfig().mcp_bridge?.approval_timeout).toBe(120);
186
+ });
187
+
188
+ it('should parse trusted tools from environment', () => {
189
+ process.env.OPENAI_API_KEY = 'test-key';
190
+ process.env.MCP_BRIDGE_ENABLED = 'true';
191
+ process.env.MCP_TRUSTED_TOOLS = 'tool1, tool2, tool3';
192
+
193
+ const configManager = new ConfigManager();
194
+ expect(configManager.getConfig().mcp_bridge?.trusted_tools).toEqual(['tool1', 'tool2', 'tool3']);
195
+ });
196
+
197
+ it('should parse trusted tools by server from environment', () => {
198
+ process.env.OPENAI_API_KEY = 'test-key';
199
+ process.env.MCP_BRIDGE_ENABLED = 'true';
200
+ process.env.MCP_TRUSTED_TOOLS_FILESYSTEM = 'read_file,write_file';
201
+
202
+ const configManager = new ConfigManager();
203
+ const trustedByServer = configManager.getConfig().mcp_bridge?.trusted_tools_by_server;
204
+ expect(trustedByServer?.filesystem).toEqual(['read_file', 'write_file']);
205
+ });
206
+
207
+ it('should handle wildcard trusted tools for server', () => {
208
+ process.env.OPENAI_API_KEY = 'test-key';
209
+ process.env.MCP_BRIDGE_ENABLED = 'true';
210
+ process.env.MCP_TRUSTED_TOOLS_INTERNAL_API = '*';
211
+
212
+ const configManager = new ConfigManager();
213
+ const trustedByServer = configManager.getConfig().mcp_bridge?.trusted_tools_by_server;
214
+ expect(trustedByServer?.['internal-api']).toEqual(['*']);
215
+ });
216
+
217
+ it('should auto-enable MCP bridge when MCP_SERVER_ env vars exist', () => {
218
+ process.env.OPENAI_API_KEY = 'test-key';
219
+ process.env.MCP_SERVER_TEST_TYPE = 'stdio';
220
+ process.env.MCP_SERVER_TEST_COMMAND = '/usr/bin/test-server';
221
+
222
+ const configManager = new ConfigManager();
223
+ expect(configManager.getConfig().mcp_bridge?.enabled).toBe(true);
224
+ });
225
+
226
+ it('should set approval mode to trusted', () => {
227
+ process.env.OPENAI_API_KEY = 'test-key';
228
+ process.env.MCP_BRIDGE_ENABLED = 'true';
229
+ process.env.MCP_APPROVAL_MODE = 'trusted';
230
+
231
+ const configManager = new ConfigManager();
232
+ expect(configManager.getConfig().mcp_bridge?.approval_mode).toBe('trusted');
233
+ });
234
+
235
+ it('should set approval mode to never', () => {
236
+ process.env.OPENAI_API_KEY = 'test-key';
237
+ process.env.MCP_BRIDGE_ENABLED = 'true';
238
+ process.env.MCP_APPROVAL_MODE = 'never';
239
+
240
+ const configManager = new ConfigManager();
241
+ expect(configManager.getConfig().mcp_bridge?.approval_mode).toBe('never');
242
+ });
243
+ });
244
+
245
+ describe('ConfigManager - MCP Server Config from Environment', () => {
246
+ let originalEnv: NodeJS.ProcessEnv;
247
+
248
+ beforeEach(() => {
249
+ originalEnv = { ...process.env };
250
+ jest.clearAllMocks();
251
+ Object.keys(process.env).forEach(key => {
252
+ if (key.startsWith('MCP_')) delete process.env[key];
253
+ });
254
+ delete process.env.OPENAI_API_KEY;
255
+ });
256
+
257
+ afterEach(() => {
258
+ process.env = originalEnv;
259
+ });
260
+
261
+ it('should configure stdio MCP server from environment', () => {
262
+ process.env.OPENAI_API_KEY = 'test-key';
263
+ process.env.MCP_SERVER_FILESYSTEM_TYPE = 'stdio';
264
+ process.env.MCP_SERVER_FILESYSTEM_COMMAND = 'npx @modelcontextprotocol/server-filesystem';
265
+ process.env.MCP_SERVER_FILESYSTEM_ARGS = '/home/user,/tmp';
266
+
267
+ const configManager = new ConfigManager();
268
+ const servers = configManager.getConfig().mcp_bridge?.mcp_servers;
269
+
270
+ expect(servers).toBeDefined();
271
+ expect(servers?.length).toBeGreaterThan(0);
272
+ const fsServer = servers?.find(s => s.name === 'filesystem');
273
+ expect(fsServer).toBeDefined();
274
+ expect(fsServer?.type).toBe('stdio');
275
+ expect(fsServer?.command).toBe('npx @modelcontextprotocol/server-filesystem');
276
+ expect(fsServer?.args).toEqual(['/home/user', '/tmp']);
277
+ });
278
+
279
+ it('should configure http MCP server from environment', () => {
280
+ process.env.OPENAI_API_KEY = 'test-key';
281
+ process.env.MCP_SERVER_MYAPI_TYPE = 'http';
282
+ process.env.MCP_SERVER_MYAPI_URL = 'https://api.example.com/mcp';
283
+ process.env.MCP_SERVER_MYAPI_API_KEY = 'server-api-key';
284
+
285
+ const configManager = new ConfigManager();
286
+ const servers = configManager.getConfig().mcp_bridge?.mcp_servers;
287
+
288
+ const httpServer = servers?.find(s => s.name === 'myapi');
289
+ expect(httpServer).toBeDefined();
290
+ expect(httpServer?.type).toBe('http');
291
+ expect(httpServer?.url).toBe('https://api.example.com/mcp');
292
+ expect(httpServer?.apiKey).toBe('server-api-key');
293
+ });
294
+
295
+ it('should skip stdio server without command', () => {
296
+ process.env.OPENAI_API_KEY = 'test-key';
297
+ process.env.MCP_SERVER_NOCOMMAND_TYPE = 'stdio';
298
+ // Missing COMMAND
299
+
300
+ const configManager = new ConfigManager();
301
+ const servers = configManager.getConfig().mcp_bridge?.mcp_servers;
302
+
303
+ const nocommandServer = servers?.find(s => s.name === 'nocommand');
304
+ expect(nocommandServer).toBeUndefined();
305
+ });
306
+
307
+ it('should skip http server without URL', () => {
308
+ process.env.OPENAI_API_KEY = 'test-key';
309
+ process.env.MCP_SERVER_NOURL_TYPE = 'http';
310
+ // Missing URL
311
+
312
+ const configManager = new ConfigManager();
313
+ const servers = configManager.getConfig().mcp_bridge?.mcp_servers;
314
+
315
+ const nourlServer = servers?.find(s => s.name === 'nourl');
316
+ expect(nourlServer).toBeUndefined();
317
+ });
318
+
319
+ it('should handle disabled MCP server', () => {
320
+ process.env.OPENAI_API_KEY = 'test-key';
321
+ process.env.MCP_SERVER_DISABLED_TYPE = 'stdio';
322
+ process.env.MCP_SERVER_DISABLED_COMMAND = 'some-command';
323
+ process.env.MCP_SERVER_DISABLED_ENABLED = 'false';
324
+
325
+ const configManager = new ConfigManager();
326
+ const servers = configManager.getConfig().mcp_bridge?.mcp_servers;
327
+
328
+ const disabledServer = servers?.find(s => s.name === 'disabled');
329
+ expect(disabledServer?.enabled).toBe(false);
330
+ });
331
+
332
+ it('should configure retry settings from environment', () => {
333
+ process.env.OPENAI_API_KEY = 'test-key';
334
+ process.env.MCP_SERVER_RETRYTEST_TYPE = 'stdio';
335
+ process.env.MCP_SERVER_RETRYTEST_COMMAND = 'test-command';
336
+ process.env.MCP_SERVER_RETRYTEST_RETRY_ATTEMPTS = '5';
337
+ process.env.MCP_SERVER_RETRYTEST_RETRY_DELAY = '2000';
338
+
339
+ const configManager = new ConfigManager();
340
+ const servers = configManager.getConfig().mcp_bridge?.mcp_servers;
341
+
342
+ const retryServer = servers?.find(s => s.name === 'retrytest');
343
+ expect(retryServer?.retryAttempts).toBe(5);
344
+ expect(retryServer?.retryDelay).toBe(2000);
345
+ });
346
+
347
+ it('should convert server names to lowercase with hyphens', () => {
348
+ process.env.OPENAI_API_KEY = 'test-key';
349
+ process.env.MCP_SERVER_MY_FANCY_SERVER_TYPE = 'stdio';
350
+ process.env.MCP_SERVER_MY_FANCY_SERVER_COMMAND = 'fancy-command';
351
+
352
+ const configManager = new ConfigManager();
353
+ const servers = configManager.getConfig().mcp_bridge?.mcp_servers;
354
+
355
+ const fancyServer = servers?.find(s => s.name === 'my-fancy-server');
356
+ expect(fancyServer).toBeDefined();
357
+ });
358
+ });
359
+
360
+ describe('ConfigManager - Public Methods', () => {
361
+ let originalEnv: NodeJS.ProcessEnv;
362
+
363
+ beforeEach(() => {
364
+ originalEnv = { ...process.env };
365
+ jest.clearAllMocks();
366
+ delete process.env.OPENAI_API_KEY;
367
+ delete process.env.GEMINI_API_KEY;
368
+ Object.keys(process.env).forEach(key => {
369
+ if (key.startsWith('CUSTOM_')) delete process.env[key];
370
+ });
371
+ });
372
+
373
+ afterEach(() => {
374
+ process.env = originalEnv;
375
+ });
376
+
377
+ describe('getProvider', () => {
378
+ it('should return specific provider by name', () => {
379
+ process.env.OPENAI_API_KEY = 'openai-key';
380
+ process.env.GEMINI_API_KEY = 'gemini-key';
381
+
382
+ const configManager = new ConfigManager();
383
+ const provider = configManager.getProvider('openai');
384
+
385
+ expect(provider).toBeDefined();
386
+ expect(provider.api_key).toBe('openai-key');
387
+ });
388
+
389
+ it('should return undefined for non-existent provider', () => {
390
+ process.env.OPENAI_API_KEY = 'openai-key';
391
+
392
+ const configManager = new ConfigManager();
393
+ const provider = configManager.getProvider('nonexistent');
394
+
395
+ expect(provider).toBeUndefined();
396
+ });
397
+ });
398
+
399
+ describe('getDefaultProvider', () => {
400
+ it('should return the default provider', () => {
401
+ process.env.OPENAI_API_KEY = 'openai-key';
402
+ process.env.DEFAULT_PROVIDER = 'openai';
403
+
404
+ const configManager = new ConfigManager();
405
+ const provider = configManager.getDefaultProvider();
406
+
407
+ expect(provider).toBeDefined();
408
+ expect(provider.api_key).toBe('openai-key');
409
+ });
410
+
411
+ it('should use first provider as default when none specified', () => {
412
+ process.env.OPENAI_API_KEY = 'openai-key';
413
+ // No DEFAULT_PROVIDER set
414
+
415
+ const configManager = new ConfigManager();
416
+ const provider = configManager.getDefaultProvider();
417
+
418
+ expect(provider).toBeDefined();
419
+ expect(provider.api_key).toBe('openai-key');
420
+ });
421
+ });
422
+
423
+ describe('updateConfig', () => {
424
+ it('should update config with partial updates', () => {
425
+ process.env.OPENAI_API_KEY = 'openai-key';
426
+
427
+ const configManager = new ConfigManager();
428
+ const originalTemp = configManager.getConfig().default_temperature;
429
+
430
+ configManager.updateConfig({ default_temperature: 0.99 });
431
+
432
+ expect(configManager.getConfig().default_temperature).toBe(0.99);
433
+ expect(configManager.getConfig().default_temperature).not.toBe(originalTemp);
434
+ });
435
+
436
+ it('should preserve existing config when updating', () => {
437
+ process.env.OPENAI_API_KEY = 'openai-key';
438
+
439
+ const configManager = new ConfigManager();
440
+ configManager.updateConfig({ log_level: 'debug' });
441
+
442
+ expect(configManager.getConfig().log_level).toBe('debug');
443
+ expect(configManager.getConfig().providers.openai).toBeDefined();
444
+ });
445
+ });
446
+
447
+ describe('environment overrides', () => {
448
+ it('should override default_provider from environment', () => {
449
+ process.env.OPENAI_API_KEY = 'openai-key';
450
+ process.env.GEMINI_API_KEY = 'gemini-key';
451
+ process.env.DEFAULT_PROVIDER = 'gemini';
452
+
453
+ const configManager = new ConfigManager();
454
+ expect(configManager.getConfig().default_provider).toBe('gemini');
455
+ });
456
+
457
+ it('should override default_temperature from environment', () => {
458
+ process.env.OPENAI_API_KEY = 'openai-key';
459
+ process.env.DEFAULT_TEMPERATURE = '0.9';
460
+
461
+ const configManager = new ConfigManager();
462
+ expect(configManager.getConfig().default_temperature).toBe(0.9);
463
+ });
464
+
465
+ it('should override log_level from environment', () => {
466
+ process.env.OPENAI_API_KEY = 'openai-key';
467
+ process.env.LOG_LEVEL = 'debug';
468
+
469
+ const configManager = new ConfigManager();
470
+ expect(configManager.getConfig().log_level).toBe('debug');
471
+ });
472
+ });
473
+ });
474
+
7
475
  describe('ConfigManager - Custom Providers', () => {
8
476
  let originalEnv: NodeJS.ProcessEnv;
9
477
 
@@ -121,6 +121,16 @@ describe('ConsensusService', () => {
121
121
  expect(result.choice).toBe('');
122
122
  });
123
123
 
124
+ it('should handle malformed JSON that looks like JSON', () => {
125
+ // This matches the JSON regex but fails JSON.parse
126
+ const response = '{"choice": Option A, confidence: 80}'; // Invalid JSON - missing quotes
127
+
128
+ const result = service.parseVote(response, 'provider1', 'Duck 1', options);
129
+ // Should fallback and try to extract from text
130
+ expect(result.choice).toBe('Option A');
131
+ expect(result.confidence).toBe(50); // Default fallback confidence
132
+ });
133
+
124
134
  it('should parse string confidence values', () => {
125
135
  const response = JSON.stringify({
126
136
  choice: 'Option A',
@@ -305,4 +305,90 @@ describe('ConversationManager', () => {
305
305
  expect(conversation!.provider).toBe('groq');
306
306
  });
307
307
  });
308
+
309
+ describe('clearOldConversations', () => {
310
+ it('should not delete recent conversations', () => {
311
+ conversationManager.createConversation('test-1', 'openai');
312
+ conversationManager.createConversation('test-2', 'groq');
313
+
314
+ // Clear with default maxAge (24 hours)
315
+ conversationManager.clearOldConversations();
316
+
317
+ // Both conversations should still exist
318
+ expect(conversationManager.getConversation('test-1')).toBeDefined();
319
+ expect(conversationManager.getConversation('test-2')).toBeDefined();
320
+ });
321
+
322
+ it('should delete conversations older than maxAge', () => {
323
+ // Create conversations with mocked old dates
324
+ const oldDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); // 2 days ago
325
+
326
+ conversationManager.createConversation('old-1', 'openai');
327
+ conversationManager.createConversation('recent-1', 'groq');
328
+
329
+ // Manually set the updatedAt to simulate old conversation
330
+ const oldConversation = conversationManager.getConversation('old-1')!;
331
+ oldConversation.updatedAt = oldDate;
332
+
333
+ // Clear with 24 hour maxAge
334
+ conversationManager.clearOldConversations(24 * 60 * 60 * 1000);
335
+
336
+ // Old conversation should be deleted
337
+ expect(conversationManager.getConversation('old-1')).toBeUndefined();
338
+ // Recent conversation should still exist
339
+ expect(conversationManager.getConversation('recent-1')).toBeDefined();
340
+ });
341
+
342
+ it('should delete multiple old conversations', () => {
343
+ const oldDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
344
+
345
+ conversationManager.createConversation('old-1', 'openai');
346
+ conversationManager.createConversation('old-2', 'groq');
347
+ conversationManager.createConversation('recent-1', 'gemini');
348
+
349
+ const old1 = conversationManager.getConversation('old-1')!;
350
+ const old2 = conversationManager.getConversation('old-2')!;
351
+ old1.updatedAt = oldDate;
352
+ old2.updatedAt = oldDate;
353
+
354
+ conversationManager.clearOldConversations();
355
+
356
+ expect(conversationManager.getConversation('old-1')).toBeUndefined();
357
+ expect(conversationManager.getConversation('old-2')).toBeUndefined();
358
+ expect(conversationManager.getConversation('recent-1')).toBeDefined();
359
+ expect(conversationManager.listConversations()).toHaveLength(1);
360
+ });
361
+
362
+ it('should handle empty conversation list', () => {
363
+ // Should not throw
364
+ expect(() => conversationManager.clearOldConversations()).not.toThrow();
365
+ });
366
+
367
+ it('should use custom maxAge parameter', () => {
368
+ conversationManager.createConversation('test-1', 'openai');
369
+
370
+ const conv = conversationManager.getConversation('test-1')!;
371
+ // Set updatedAt to 1 hour ago
372
+ conv.updatedAt = new Date(Date.now() - 60 * 60 * 1000);
373
+
374
+ // Clear with 30 minute maxAge (should delete)
375
+ conversationManager.clearOldConversations(30 * 60 * 1000);
376
+
377
+ expect(conversationManager.getConversation('test-1')).toBeUndefined();
378
+ });
379
+
380
+ it('should not delete conversations exactly at maxAge boundary', () => {
381
+ conversationManager.createConversation('test-1', 'openai');
382
+
383
+ const conv = conversationManager.getConversation('test-1')!;
384
+ // Set to exactly maxAge ago (should NOT be deleted as condition is >)
385
+ const maxAge = 60 * 60 * 1000;
386
+ conv.updatedAt = new Date(Date.now() - maxAge);
387
+
388
+ conversationManager.clearOldConversations(maxAge);
389
+
390
+ // Should still exist (edge case: equal time is not "older than")
391
+ expect(conversationManager.getConversation('test-1')).toBeDefined();
392
+ });
393
+ });
308
394
  });