mcp-rubber-duck 1.9.4 → 1.10.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/.eslintrc.json +3 -1
- package/CHANGELOG.md +19 -0
- package/README.md +54 -10
- package/assets/ext-apps-compare.png +0 -0
- package/assets/ext-apps-debate.png +0 -0
- package/assets/ext-apps-usage-stats.png +0 -0
- package/assets/ext-apps-vote.png +0 -0
- package/audit-ci.json +3 -1
- package/dist/server.d.ts +5 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +414 -498
- package/dist/server.js.map +1 -1
- package/dist/tools/compare-ducks.d.ts.map +1 -1
- package/dist/tools/compare-ducks.js +19 -0
- package/dist/tools/compare-ducks.js.map +1 -1
- package/dist/tools/duck-debate.d.ts.map +1 -1
- package/dist/tools/duck-debate.js +24 -0
- package/dist/tools/duck-debate.js.map +1 -1
- package/dist/tools/duck-vote.d.ts.map +1 -1
- package/dist/tools/duck-vote.js +23 -0
- package/dist/tools/duck-vote.js.map +1 -1
- package/dist/tools/get-usage-stats.d.ts.map +1 -1
- package/dist/tools/get-usage-stats.js +13 -0
- package/dist/tools/get-usage-stats.js.map +1 -1
- package/dist/ui/compare-ducks/mcp-app.html +187 -0
- package/dist/ui/duck-debate/mcp-app.html +182 -0
- package/dist/ui/duck-vote/mcp-app.html +168 -0
- package/dist/ui/usage-stats/mcp-app.html +192 -0
- package/jest.config.js +1 -0
- package/package.json +7 -3
- package/src/server.ts +491 -523
- package/src/tools/compare-ducks.ts +20 -0
- package/src/tools/duck-debate.ts +27 -0
- package/src/tools/duck-vote.ts +24 -0
- package/src/tools/get-usage-stats.ts +14 -0
- package/src/ui/compare-ducks/app.ts +88 -0
- package/src/ui/compare-ducks/mcp-app.html +102 -0
- package/src/ui/duck-debate/app.ts +111 -0
- package/src/ui/duck-debate/mcp-app.html +97 -0
- package/src/ui/duck-vote/app.ts +128 -0
- package/src/ui/duck-vote/mcp-app.html +83 -0
- package/src/ui/usage-stats/app.ts +156 -0
- package/src/ui/usage-stats/mcp-app.html +107 -0
- package/tests/duck-debate.test.ts +3 -1
- package/tests/duck-vote.test.ts +3 -1
- package/tests/tool-annotations.test.ts +208 -41
- package/tests/tools/compare-ducks-ui.test.ts +135 -0
- package/tests/tools/compare-ducks.test.ts +3 -1
- package/tests/tools/duck-debate-ui.test.ts +234 -0
- package/tests/tools/duck-vote-ui.test.ts +172 -0
- package/tests/tools/get-usage-stats.test.ts +3 -1
- package/tests/tools/usage-stats-ui.test.ts +130 -0
- package/tests/ui-build.test.ts +53 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +19 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
|
1
|
+
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
|
2
2
|
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
4
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
3
5
|
|
|
4
6
|
// We need to test the tool annotations from RubberDuckServer
|
|
5
|
-
//
|
|
7
|
+
// Using the proper MCP protocol to list tools via Client + InMemoryTransport
|
|
6
8
|
|
|
7
9
|
// Mock dependencies before importing the server
|
|
8
10
|
jest.mock('../src/utils/logger');
|
|
@@ -29,17 +31,32 @@ import { RubberDuckServer } from '../src/server.js';
|
|
|
29
31
|
*/
|
|
30
32
|
describe('Tool Annotations', () => {
|
|
31
33
|
let server: RubberDuckServer;
|
|
34
|
+
let client: Client;
|
|
32
35
|
let tools: Tool[];
|
|
33
36
|
|
|
34
|
-
beforeEach(() => {
|
|
37
|
+
beforeEach(async () => {
|
|
35
38
|
// Set up minimal environment for server initialization
|
|
36
39
|
process.env.OPENAI_API_KEY = 'test-key';
|
|
37
40
|
|
|
38
41
|
server = new RubberDuckServer();
|
|
39
42
|
|
|
40
|
-
//
|
|
43
|
+
// Create in-memory client-server pair
|
|
44
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
45
|
+
|
|
46
|
+
// Connect server (access underlying McpServer)
|
|
41
47
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
-
|
|
48
|
+
await (server as any).server.connect(serverTransport);
|
|
49
|
+
|
|
50
|
+
client = new Client({ name: 'test-client', version: '1.0.0' });
|
|
51
|
+
await client.connect(clientTransport);
|
|
52
|
+
|
|
53
|
+
// List tools via proper MCP protocol
|
|
54
|
+
const result = await client.listTools();
|
|
55
|
+
tools = result.tools;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
await client.close();
|
|
43
60
|
});
|
|
44
61
|
|
|
45
62
|
// Helper to find a tool by name
|
|
@@ -355,8 +372,11 @@ describe('Tool Annotations', () => {
|
|
|
355
372
|
});
|
|
356
373
|
|
|
357
374
|
describe('Base tools count', () => {
|
|
358
|
-
it('should have 12 base tools', () => {
|
|
359
|
-
|
|
375
|
+
it('should have exactly 12 base tools', () => {
|
|
376
|
+
expect(tools).toHaveLength(12);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('should have all expected base tool names', () => {
|
|
360
380
|
const baseToolNames = [
|
|
361
381
|
'ask_duck',
|
|
362
382
|
'chat_with_duck',
|
|
@@ -377,13 +397,159 @@ describe('Tool Annotations', () => {
|
|
|
377
397
|
}
|
|
378
398
|
});
|
|
379
399
|
});
|
|
400
|
+
|
|
401
|
+
describe('Tool input schemas (Zod migration correctness)', () => {
|
|
402
|
+
/**
|
|
403
|
+
* These tests verify that the JSON Schema → Zod conversion
|
|
404
|
+
* preserved required fields, property names, and types correctly.
|
|
405
|
+
*/
|
|
406
|
+
|
|
407
|
+
it('ask_duck should have prompt as required and provider/model/temperature as optional', () => {
|
|
408
|
+
const tool = findTool('ask_duck');
|
|
409
|
+
expect(tool?.inputSchema.required).toContain('prompt');
|
|
410
|
+
expect(tool?.inputSchema.required).not.toContain('provider');
|
|
411
|
+
expect(tool?.inputSchema.required).not.toContain('model');
|
|
412
|
+
expect(tool?.inputSchema.required).not.toContain('temperature');
|
|
413
|
+
expect(tool?.inputSchema.properties).toHaveProperty('prompt');
|
|
414
|
+
expect(tool?.inputSchema.properties).toHaveProperty('provider');
|
|
415
|
+
expect(tool?.inputSchema.properties).toHaveProperty('model');
|
|
416
|
+
expect(tool?.inputSchema.properties).toHaveProperty('temperature');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('chat_with_duck should have conversation_id and message as required', () => {
|
|
420
|
+
const tool = findTool('chat_with_duck');
|
|
421
|
+
expect(tool?.inputSchema.required).toContain('conversation_id');
|
|
422
|
+
expect(tool?.inputSchema.required).toContain('message');
|
|
423
|
+
expect(tool?.inputSchema.required).not.toContain('provider');
|
|
424
|
+
expect(tool?.inputSchema.required).not.toContain('model');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('clear_conversations should have no required properties', () => {
|
|
428
|
+
const tool = findTool('clear_conversations');
|
|
429
|
+
// No inputSchema properties expected (no args tool)
|
|
430
|
+
const required = tool?.inputSchema.required || [];
|
|
431
|
+
expect(required).toHaveLength(0);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('compare_ducks should have prompt as required and providers/model optional', () => {
|
|
435
|
+
const tool = findTool('compare_ducks');
|
|
436
|
+
expect(tool?.inputSchema.required).toContain('prompt');
|
|
437
|
+
expect(tool?.inputSchema.required).not.toContain('providers');
|
|
438
|
+
expect(tool?.inputSchema.properties?.providers).toHaveProperty('type', 'array');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('duck_vote should have question and options as required', () => {
|
|
442
|
+
const tool = findTool('duck_vote');
|
|
443
|
+
expect(tool?.inputSchema.required).toContain('question');
|
|
444
|
+
expect(tool?.inputSchema.required).toContain('options');
|
|
445
|
+
expect(tool?.inputSchema.required).not.toContain('voters');
|
|
446
|
+
expect(tool?.inputSchema.required).not.toContain('require_reasoning');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('duck_judge should have responses as required with nested object schema', () => {
|
|
450
|
+
const tool = findTool('duck_judge');
|
|
451
|
+
expect(tool?.inputSchema.required).toContain('responses');
|
|
452
|
+
expect(tool?.inputSchema.required).not.toContain('judge');
|
|
453
|
+
expect(tool?.inputSchema.required).not.toContain('criteria');
|
|
454
|
+
expect(tool?.inputSchema.required).not.toContain('persona');
|
|
455
|
+
// responses should be an array type
|
|
456
|
+
const responses = tool?.inputSchema.properties?.responses as Record<string, unknown>;
|
|
457
|
+
expect(responses?.type).toBe('array');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('duck_iterate should have prompt, providers, and mode as required', () => {
|
|
461
|
+
const tool = findTool('duck_iterate');
|
|
462
|
+
expect(tool?.inputSchema.required).toContain('prompt');
|
|
463
|
+
expect(tool?.inputSchema.required).toContain('providers');
|
|
464
|
+
expect(tool?.inputSchema.required).toContain('mode');
|
|
465
|
+
expect(tool?.inputSchema.required).not.toContain('iterations');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('duck_debate should have prompt and format as required', () => {
|
|
469
|
+
const tool = findTool('duck_debate');
|
|
470
|
+
expect(tool?.inputSchema.required).toContain('prompt');
|
|
471
|
+
expect(tool?.inputSchema.required).toContain('format');
|
|
472
|
+
expect(tool?.inputSchema.required).not.toContain('rounds');
|
|
473
|
+
expect(tool?.inputSchema.required).not.toContain('providers');
|
|
474
|
+
expect(tool?.inputSchema.required).not.toContain('synthesizer');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('get_usage_stats should have no required properties (period has default)', () => {
|
|
478
|
+
const tool = findTool('get_usage_stats');
|
|
479
|
+
const required = tool?.inputSchema.required || [];
|
|
480
|
+
expect(required).not.toContain('period');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('all tools should have descriptions', () => {
|
|
484
|
+
for (const tool of tools) {
|
|
485
|
+
expect(tool.description).toBeDefined();
|
|
486
|
+
expect(typeof tool.description).toBe('string');
|
|
487
|
+
expect(tool.description!.length).toBeGreaterThan(0);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
describe('Prompts registration', () => {
|
|
493
|
+
it('should register all 8 prompts via MCP protocol', async () => {
|
|
494
|
+
const result = await client.listPrompts();
|
|
495
|
+
expect(result.prompts).toHaveLength(8);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('should register prompts with correct names', async () => {
|
|
499
|
+
const result = await client.listPrompts();
|
|
500
|
+
const names = result.prompts.map((p) => p.name);
|
|
501
|
+
const expectedNames = [
|
|
502
|
+
'perspectives',
|
|
503
|
+
'assumptions',
|
|
504
|
+
'blindspots',
|
|
505
|
+
'tradeoffs',
|
|
506
|
+
'red_team',
|
|
507
|
+
'reframe',
|
|
508
|
+
'architecture',
|
|
509
|
+
'diverge_converge',
|
|
510
|
+
];
|
|
511
|
+
for (const name of expectedNames) {
|
|
512
|
+
expect(names).toContain(name);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('should register prompts with descriptions', async () => {
|
|
517
|
+
const result = await client.listPrompts();
|
|
518
|
+
for (const prompt of result.prompts) {
|
|
519
|
+
expect(prompt.description).toBeDefined();
|
|
520
|
+
expect(typeof prompt.description).toBe('string');
|
|
521
|
+
expect(prompt.description!.length).toBeGreaterThan(0);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('should return prompt messages via getPrompt', async () => {
|
|
526
|
+
const result = await client.getPrompt({
|
|
527
|
+
name: 'reframe',
|
|
528
|
+
arguments: { problem: 'Test problem' },
|
|
529
|
+
});
|
|
530
|
+
expect(result.messages).toBeDefined();
|
|
531
|
+
expect(result.messages.length).toBeGreaterThan(0);
|
|
532
|
+
expect(result.messages[0].role).toBe('user');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('should return prompt messages containing the user input', async () => {
|
|
536
|
+
const result = await client.getPrompt({
|
|
537
|
+
name: 'perspectives',
|
|
538
|
+
arguments: { problem: 'My test problem', perspectives: 'security, perf' },
|
|
539
|
+
});
|
|
540
|
+
const text = (result.messages[0].content as { type: string; text: string }).text;
|
|
541
|
+
expect(text).toContain('My test problem');
|
|
542
|
+
expect(text).toContain('security, perf');
|
|
543
|
+
});
|
|
544
|
+
});
|
|
380
545
|
});
|
|
381
546
|
|
|
382
547
|
describe('MCP-specific Tool Annotations', () => {
|
|
383
548
|
let server: RubberDuckServer;
|
|
549
|
+
let client: Client;
|
|
384
550
|
let tools: Tool[];
|
|
385
551
|
|
|
386
|
-
beforeEach(() => {
|
|
552
|
+
beforeEach(async () => {
|
|
387
553
|
// Enable MCP bridge for these tests
|
|
388
554
|
process.env.OPENAI_API_KEY = 'test-key';
|
|
389
555
|
process.env.MCP_BRIDGE_ENABLED = 'true';
|
|
@@ -393,13 +559,24 @@ describe('MCP-specific Tool Annotations', () => {
|
|
|
393
559
|
|
|
394
560
|
server = new RubberDuckServer();
|
|
395
561
|
|
|
396
|
-
//
|
|
562
|
+
// Create in-memory client-server pair
|
|
563
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
564
|
+
|
|
565
|
+
// Connect server (access underlying McpServer)
|
|
397
566
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
398
|
-
|
|
567
|
+
await (server as any).server.connect(serverTransport);
|
|
568
|
+
|
|
569
|
+
client = new Client({ name: 'test-client', version: '1.0.0' });
|
|
570
|
+
await client.connect(clientTransport);
|
|
571
|
+
|
|
572
|
+
// List tools via proper MCP protocol
|
|
573
|
+
const result = await client.listTools();
|
|
574
|
+
tools = result.tools;
|
|
399
575
|
});
|
|
400
576
|
|
|
401
|
-
afterEach(() => {
|
|
577
|
+
afterEach(async () => {
|
|
402
578
|
delete process.env.MCP_BRIDGE_ENABLED;
|
|
579
|
+
await client.close();
|
|
403
580
|
});
|
|
404
581
|
|
|
405
582
|
// Helper to find a tool by name
|
|
@@ -407,8 +584,12 @@ describe('MCP-specific Tool Annotations', () => {
|
|
|
407
584
|
return tools.find((t) => t.name === name);
|
|
408
585
|
};
|
|
409
586
|
|
|
410
|
-
|
|
411
|
-
|
|
587
|
+
it('should register 15 tools when MCP bridge is enabled', () => {
|
|
588
|
+
expect(tools).toHaveLength(15);
|
|
589
|
+
expect(findTool('get_pending_approvals')).toBeDefined();
|
|
590
|
+
expect(findTool('approve_mcp_request')).toBeDefined();
|
|
591
|
+
expect(findTool('mcp_status')).toBeDefined();
|
|
592
|
+
});
|
|
412
593
|
|
|
413
594
|
describe('get_pending_approvals (when MCP enabled)', () => {
|
|
414
595
|
/**
|
|
@@ -417,18 +598,14 @@ describe('MCP-specific Tool Annotations', () => {
|
|
|
417
598
|
* - readOnlyHint: true - Only reads approval state
|
|
418
599
|
* - openWorldHint: NOT set - Pure local operation
|
|
419
600
|
*/
|
|
420
|
-
it('should be marked as read-only
|
|
601
|
+
it('should be marked as read-only', () => {
|
|
421
602
|
const tool = findTool('get_pending_approvals');
|
|
422
|
-
|
|
423
|
-
expect(tool.annotations?.readOnlyHint).toBe(true);
|
|
424
|
-
}
|
|
603
|
+
expect(tool?.annotations?.readOnlyHint).toBe(true);
|
|
425
604
|
});
|
|
426
605
|
|
|
427
|
-
it('should be explicitly marked as NOT open-world
|
|
606
|
+
it('should be explicitly marked as NOT open-world', () => {
|
|
428
607
|
const tool = findTool('get_pending_approvals');
|
|
429
|
-
|
|
430
|
-
expect(tool.annotations?.openWorldHint).toBe(false);
|
|
431
|
-
}
|
|
608
|
+
expect(tool?.annotations?.openWorldHint).toBe(false);
|
|
432
609
|
});
|
|
433
610
|
});
|
|
434
611
|
|
|
@@ -441,25 +618,19 @@ describe('MCP-specific Tool Annotations', () => {
|
|
|
441
618
|
* - readOnlyHint: NOT set - Modifies approval state
|
|
442
619
|
* - openWorldHint: NOT set - Pure local operation
|
|
443
620
|
*/
|
|
444
|
-
it('should be marked as idempotent
|
|
621
|
+
it('should be marked as idempotent', () => {
|
|
445
622
|
const tool = findTool('approve_mcp_request');
|
|
446
|
-
|
|
447
|
-
expect(tool.annotations?.idempotentHint).toBe(true);
|
|
448
|
-
}
|
|
623
|
+
expect(tool?.annotations?.idempotentHint).toBe(true);
|
|
449
624
|
});
|
|
450
625
|
|
|
451
|
-
it('should NOT be marked as read-only
|
|
626
|
+
it('should NOT be marked as read-only', () => {
|
|
452
627
|
const tool = findTool('approve_mcp_request');
|
|
453
|
-
|
|
454
|
-
expect(tool.annotations?.readOnlyHint).toBeUndefined();
|
|
455
|
-
}
|
|
628
|
+
expect(tool?.annotations?.readOnlyHint).toBeUndefined();
|
|
456
629
|
});
|
|
457
630
|
|
|
458
|
-
it('should be explicitly marked as NOT open-world
|
|
631
|
+
it('should be explicitly marked as NOT open-world', () => {
|
|
459
632
|
const tool = findTool('approve_mcp_request');
|
|
460
|
-
|
|
461
|
-
expect(tool.annotations?.openWorldHint).toBe(false);
|
|
462
|
-
}
|
|
633
|
+
expect(tool?.annotations?.openWorldHint).toBe(false);
|
|
463
634
|
});
|
|
464
635
|
});
|
|
465
636
|
|
|
@@ -471,18 +642,14 @@ describe('MCP-specific Tool Annotations', () => {
|
|
|
471
642
|
* - readOnlyHint: true - Only reads status information
|
|
472
643
|
* - openWorldHint: true - Communicates with MCP servers
|
|
473
644
|
*/
|
|
474
|
-
it('should be marked as read-only
|
|
645
|
+
it('should be marked as read-only', () => {
|
|
475
646
|
const tool = findTool('mcp_status');
|
|
476
|
-
|
|
477
|
-
expect(tool.annotations?.readOnlyHint).toBe(true);
|
|
478
|
-
}
|
|
647
|
+
expect(tool?.annotations?.readOnlyHint).toBe(true);
|
|
479
648
|
});
|
|
480
649
|
|
|
481
|
-
it('should be marked as open-world
|
|
650
|
+
it('should be marked as open-world', () => {
|
|
482
651
|
const tool = findTool('mcp_status');
|
|
483
|
-
|
|
484
|
-
expect(tool.annotations?.openWorldHint).toBe(true);
|
|
485
|
-
}
|
|
652
|
+
expect(tool?.annotations?.openWorldHint).toBe(true);
|
|
486
653
|
});
|
|
487
654
|
});
|
|
488
655
|
});
|
|
@@ -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(
|
|
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 () => {
|