mcp-rubber-duck 1.9.5 → 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 +12 -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 +2 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +62 -4
- 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 +79 -4
- 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/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
|
@@ -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 () => {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
// Mock OpenAI BEFORE importing
|
|
4
|
+
const mockCreate = jest.fn();
|
|
5
|
+
jest.mock('openai', () => {
|
|
6
|
+
const MockOpenAI = jest.fn().mockImplementation(() => ({
|
|
7
|
+
chat: { completions: { create: mockCreate } },
|
|
8
|
+
}));
|
|
9
|
+
return { __esModule: true, default: MockOpenAI };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
jest.mock('../../src/config/config');
|
|
13
|
+
jest.mock('../../src/utils/logger');
|
|
14
|
+
|
|
15
|
+
import { duckDebateTool } from '../../src/tools/duck-debate';
|
|
16
|
+
import { ProviderManager } from '../../src/providers/manager';
|
|
17
|
+
import { ConfigManager } from '../../src/config/config';
|
|
18
|
+
|
|
19
|
+
describe('duckDebateTool structured JSON', () => {
|
|
20
|
+
let mockProviderManager: ProviderManager;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
jest.clearAllMocks();
|
|
24
|
+
|
|
25
|
+
const mockConfigManager = {
|
|
26
|
+
getConfig: jest.fn().mockReturnValue({
|
|
27
|
+
providers: {
|
|
28
|
+
openai: {
|
|
29
|
+
api_key: 'key1',
|
|
30
|
+
base_url: 'https://api.openai.com/v1',
|
|
31
|
+
default_model: 'gpt-4',
|
|
32
|
+
nickname: 'GPT-4',
|
|
33
|
+
models: ['gpt-4'],
|
|
34
|
+
},
|
|
35
|
+
gemini: {
|
|
36
|
+
api_key: 'key2',
|
|
37
|
+
base_url: 'https://api.gemini.com/v1',
|
|
38
|
+
default_model: 'gemini-pro',
|
|
39
|
+
nickname: 'Gemini',
|
|
40
|
+
models: ['gemini-pro'],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
default_provider: 'openai',
|
|
44
|
+
cache_ttl: 300,
|
|
45
|
+
enable_failover: true,
|
|
46
|
+
default_temperature: 0.7,
|
|
47
|
+
}),
|
|
48
|
+
} as unknown as jest.Mocked<ConfigManager>;
|
|
49
|
+
|
|
50
|
+
mockProviderManager = new ProviderManager(mockConfigManager);
|
|
51
|
+
|
|
52
|
+
const provider1 = mockProviderManager.getProvider('openai');
|
|
53
|
+
const provider2 = mockProviderManager.getProvider('gemini');
|
|
54
|
+
provider1['client'].chat.completions.create = mockCreate;
|
|
55
|
+
provider2['client'].chat.completions.create = mockCreate;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return two content items: text and JSON', async () => {
|
|
59
|
+
// 1 round, 2 participants + synthesis = 3 calls
|
|
60
|
+
mockCreate
|
|
61
|
+
.mockResolvedValueOnce({
|
|
62
|
+
choices: [{ message: { content: 'Pro argument' }, finish_reason: 'stop' }],
|
|
63
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
64
|
+
model: 'gpt-4',
|
|
65
|
+
})
|
|
66
|
+
.mockResolvedValueOnce({
|
|
67
|
+
choices: [{ message: { content: 'Con argument' }, finish_reason: 'stop' }],
|
|
68
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
69
|
+
model: 'gemini-pro',
|
|
70
|
+
})
|
|
71
|
+
.mockResolvedValueOnce({
|
|
72
|
+
choices: [{ message: { content: 'Synthesis' }, finish_reason: 'stop' }],
|
|
73
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
74
|
+
model: 'gpt-4',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const result = await duckDebateTool(mockProviderManager, {
|
|
78
|
+
prompt: 'Test topic',
|
|
79
|
+
format: 'oxford',
|
|
80
|
+
rounds: 1,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result.content).toHaveLength(2);
|
|
84
|
+
expect(result.content[0].type).toBe('text');
|
|
85
|
+
expect(result.content[1].type).toBe('text');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should include debate structure in JSON', async () => {
|
|
89
|
+
mockCreate
|
|
90
|
+
.mockResolvedValueOnce({
|
|
91
|
+
choices: [{ message: { content: 'Pro argument' }, finish_reason: 'stop' }],
|
|
92
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
93
|
+
model: 'gpt-4',
|
|
94
|
+
})
|
|
95
|
+
.mockResolvedValueOnce({
|
|
96
|
+
choices: [{ message: { content: 'Con argument' }, finish_reason: 'stop' }],
|
|
97
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
98
|
+
model: 'gemini-pro',
|
|
99
|
+
})
|
|
100
|
+
.mockResolvedValueOnce({
|
|
101
|
+
choices: [{ message: { content: 'Final synthesis' }, finish_reason: 'stop' }],
|
|
102
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
103
|
+
model: 'gpt-4',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const result = await duckDebateTool(mockProviderManager, {
|
|
107
|
+
prompt: 'Microservices vs monolith',
|
|
108
|
+
format: 'oxford',
|
|
109
|
+
rounds: 1,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const data = JSON.parse(result.content[1].text) as {
|
|
113
|
+
topic: string;
|
|
114
|
+
format: string;
|
|
115
|
+
totalRounds: number;
|
|
116
|
+
participants: { provider: string; nickname: string; position: string }[];
|
|
117
|
+
rounds: { round: number; provider: string; nickname: string; position: string; content: string }[][];
|
|
118
|
+
synthesis: string;
|
|
119
|
+
synthesizer: string;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
expect(data.topic).toBe('Microservices vs monolith');
|
|
123
|
+
expect(data.format).toBe('oxford');
|
|
124
|
+
expect(data.totalRounds).toBe(1);
|
|
125
|
+
expect(data.participants).toHaveLength(2);
|
|
126
|
+
expect(data.participants[0].position).toBe('pro');
|
|
127
|
+
expect(data.participants[1].position).toBe('con');
|
|
128
|
+
expect(data.rounds).toHaveLength(1);
|
|
129
|
+
expect(data.rounds[0]).toHaveLength(2);
|
|
130
|
+
expect(data.rounds[0][0].content).toBe('Pro argument');
|
|
131
|
+
expect(data.rounds[0][1].content).toBe('Con argument');
|
|
132
|
+
expect(data.synthesis).toBe('Final synthesis');
|
|
133
|
+
expect(data.synthesizer).toBe('openai');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should assign adversarial positions correctly in JSON', async () => {
|
|
137
|
+
mockCreate
|
|
138
|
+
.mockResolvedValueOnce({
|
|
139
|
+
choices: [{ message: { content: 'Defense' }, finish_reason: 'stop' }],
|
|
140
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
141
|
+
model: 'gpt-4',
|
|
142
|
+
})
|
|
143
|
+
.mockResolvedValueOnce({
|
|
144
|
+
choices: [{ message: { content: 'Attack' }, finish_reason: 'stop' }],
|
|
145
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
146
|
+
model: 'gemini-pro',
|
|
147
|
+
})
|
|
148
|
+
.mockResolvedValueOnce({
|
|
149
|
+
choices: [{ message: { content: 'Synthesis' }, finish_reason: 'stop' }],
|
|
150
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
151
|
+
model: 'gpt-4',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result = await duckDebateTool(mockProviderManager, {
|
|
155
|
+
prompt: 'Is AI dangerous?',
|
|
156
|
+
format: 'adversarial',
|
|
157
|
+
rounds: 1,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const data = JSON.parse(result.content[1].text) as {
|
|
161
|
+
format: string;
|
|
162
|
+
participants: { position: string }[];
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
expect(data.format).toBe('adversarial');
|
|
166
|
+
// First participant defends (pro), rest challenge (con)
|
|
167
|
+
expect(data.participants[0].position).toBe('pro');
|
|
168
|
+
expect(data.participants[1].position).toBe('con');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should preserve text content alongside JSON', async () => {
|
|
172
|
+
mockCreate
|
|
173
|
+
.mockResolvedValueOnce({
|
|
174
|
+
choices: [{ message: { content: 'Pro argument' }, finish_reason: 'stop' }],
|
|
175
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
176
|
+
model: 'gpt-4',
|
|
177
|
+
})
|
|
178
|
+
.mockResolvedValueOnce({
|
|
179
|
+
choices: [{ message: { content: 'Con argument' }, finish_reason: 'stop' }],
|
|
180
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
181
|
+
model: 'gemini-pro',
|
|
182
|
+
})
|
|
183
|
+
.mockResolvedValueOnce({
|
|
184
|
+
choices: [{ message: { content: 'Final synthesis' }, finish_reason: 'stop' }],
|
|
185
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
186
|
+
model: 'gpt-4',
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const result = await duckDebateTool(mockProviderManager, {
|
|
190
|
+
prompt: 'Test topic',
|
|
191
|
+
format: 'oxford',
|
|
192
|
+
rounds: 1,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(result.content[0].text).toContain('Oxford Debate');
|
|
196
|
+
expect(result.content[0].text).toContain('Test topic');
|
|
197
|
+
expect(result.content[0].text).toContain('Synthesis');
|
|
198
|
+
expect(result.content[0].text).toContain('ROUND 1');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should assign socratic positions correctly in JSON', async () => {
|
|
202
|
+
mockCreate
|
|
203
|
+
.mockResolvedValueOnce({
|
|
204
|
+
choices: [{ message: { content: 'Question 1' }, finish_reason: 'stop' }],
|
|
205
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
206
|
+
model: 'gpt-4',
|
|
207
|
+
})
|
|
208
|
+
.mockResolvedValueOnce({
|
|
209
|
+
choices: [{ message: { content: 'Response 1' }, finish_reason: 'stop' }],
|
|
210
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
211
|
+
model: 'gemini-pro',
|
|
212
|
+
})
|
|
213
|
+
.mockResolvedValueOnce({
|
|
214
|
+
choices: [{ message: { content: 'Synthesis' }, finish_reason: 'stop' }],
|
|
215
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
216
|
+
model: 'gpt-4',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const result = await duckDebateTool(mockProviderManager, {
|
|
220
|
+
prompt: 'What is truth?',
|
|
221
|
+
format: 'socratic',
|
|
222
|
+
rounds: 1,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const data = JSON.parse(result.content[1].text) as {
|
|
226
|
+
format: string;
|
|
227
|
+
participants: { position: string }[];
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
expect(data.format).toBe('socratic');
|
|
231
|
+
expect(data.participants[0].position).toBe('neutral');
|
|
232
|
+
expect(data.participants[1].position).toBe('neutral');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
// Mock OpenAI BEFORE importing
|
|
4
|
+
const mockCreate = jest.fn();
|
|
5
|
+
jest.mock('openai', () => {
|
|
6
|
+
const MockOpenAI = jest.fn().mockImplementation(() => ({
|
|
7
|
+
chat: { completions: { create: mockCreate } },
|
|
8
|
+
}));
|
|
9
|
+
return { __esModule: true, default: MockOpenAI };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
jest.mock('../../src/config/config');
|
|
13
|
+
jest.mock('../../src/utils/logger');
|
|
14
|
+
|
|
15
|
+
import { duckVoteTool } from '../../src/tools/duck-vote';
|
|
16
|
+
import { ProviderManager } from '../../src/providers/manager';
|
|
17
|
+
import { ConfigManager } from '../../src/config/config';
|
|
18
|
+
|
|
19
|
+
describe('duckVoteTool structured JSON', () => {
|
|
20
|
+
let mockProviderManager: ProviderManager;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
jest.clearAllMocks();
|
|
24
|
+
|
|
25
|
+
const mockConfigManager = {
|
|
26
|
+
getConfig: jest.fn().mockReturnValue({
|
|
27
|
+
providers: {
|
|
28
|
+
openai: {
|
|
29
|
+
api_key: 'key1',
|
|
30
|
+
base_url: 'https://api.openai.com/v1',
|
|
31
|
+
default_model: 'gpt-4',
|
|
32
|
+
nickname: 'GPT-4',
|
|
33
|
+
models: ['gpt-4'],
|
|
34
|
+
},
|
|
35
|
+
gemini: {
|
|
36
|
+
api_key: 'key2',
|
|
37
|
+
base_url: 'https://api.gemini.com/v1',
|
|
38
|
+
default_model: 'gemini-pro',
|
|
39
|
+
nickname: 'Gemini',
|
|
40
|
+
models: ['gemini-pro'],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
default_provider: 'openai',
|
|
44
|
+
cache_ttl: 300,
|
|
45
|
+
enable_failover: true,
|
|
46
|
+
default_temperature: 0.7,
|
|
47
|
+
}),
|
|
48
|
+
} as unknown as jest.Mocked<ConfigManager>;
|
|
49
|
+
|
|
50
|
+
mockProviderManager = new ProviderManager(mockConfigManager);
|
|
51
|
+
|
|
52
|
+
const provider1 = mockProviderManager.getProvider('openai');
|
|
53
|
+
const provider2 = mockProviderManager.getProvider('gemini');
|
|
54
|
+
provider1['client'].chat.completions.create = mockCreate;
|
|
55
|
+
provider2['client'].chat.completions.create = mockCreate;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return two content items: text and JSON', async () => {
|
|
59
|
+
mockCreate
|
|
60
|
+
.mockResolvedValueOnce({
|
|
61
|
+
choices: [{ message: { content: '{"choice": "A", "confidence": 85, "reasoning": "Solid"}' }, finish_reason: 'stop' }],
|
|
62
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
63
|
+
model: 'gpt-4',
|
|
64
|
+
})
|
|
65
|
+
.mockResolvedValueOnce({
|
|
66
|
+
choices: [{ message: { content: '{"choice": "A", "confidence": 75, "reasoning": "Good"}' }, finish_reason: 'stop' }],
|
|
67
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
68
|
+
model: 'gemini-pro',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const result = await duckVoteTool(mockProviderManager, {
|
|
72
|
+
question: 'Best?',
|
|
73
|
+
options: ['A', 'B'],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result.content).toHaveLength(2);
|
|
77
|
+
expect(result.content[0].type).toBe('text');
|
|
78
|
+
expect(result.content[1].type).toBe('text');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should include vote data in JSON', async () => {
|
|
82
|
+
mockCreate
|
|
83
|
+
.mockResolvedValueOnce({
|
|
84
|
+
choices: [{ message: { content: '{"choice": "A", "confidence": 85, "reasoning": "Best"}' }, finish_reason: 'stop' }],
|
|
85
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
86
|
+
model: 'gpt-4',
|
|
87
|
+
})
|
|
88
|
+
.mockResolvedValueOnce({
|
|
89
|
+
choices: [{ message: { content: '{"choice": "A", "confidence": 75, "reasoning": "Good"}' }, finish_reason: 'stop' }],
|
|
90
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
91
|
+
model: 'gemini-pro',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await duckVoteTool(mockProviderManager, {
|
|
95
|
+
question: 'Best approach?',
|
|
96
|
+
options: ['A', 'B'],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const data = JSON.parse(result.content[1].text) as {
|
|
100
|
+
question: string;
|
|
101
|
+
options: string[];
|
|
102
|
+
winner: string | null;
|
|
103
|
+
isTie: boolean;
|
|
104
|
+
tally: Record<string, number>;
|
|
105
|
+
confidenceByOption: Record<string, number>;
|
|
106
|
+
votes: { voter: string; nickname: string; choice: string; confidence: number; reasoning: string }[];
|
|
107
|
+
totalVoters: number;
|
|
108
|
+
validVotes: number;
|
|
109
|
+
consensusLevel: string;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
expect(data.question).toBe('Best approach?');
|
|
113
|
+
expect(data.options).toEqual(['A', 'B']);
|
|
114
|
+
expect(data.winner).toBe('A');
|
|
115
|
+
expect(data.consensusLevel).toBe('unanimous');
|
|
116
|
+
expect(data.tally['A']).toBe(2);
|
|
117
|
+
expect(data.tally['B']).toBe(0);
|
|
118
|
+
expect(data.votes).toHaveLength(2);
|
|
119
|
+
expect(data.votes[0].confidence).toBe(85);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should reflect tie in JSON data', async () => {
|
|
123
|
+
mockCreate
|
|
124
|
+
.mockResolvedValueOnce({
|
|
125
|
+
choices: [{ message: { content: '{"choice": "A", "confidence": 60}' }, finish_reason: 'stop' }],
|
|
126
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
127
|
+
model: 'gpt-4',
|
|
128
|
+
})
|
|
129
|
+
.mockResolvedValueOnce({
|
|
130
|
+
choices: [{ message: { content: '{"choice": "B", "confidence": 90}' }, finish_reason: 'stop' }],
|
|
131
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
132
|
+
model: 'gemini-pro',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const result = await duckVoteTool(mockProviderManager, {
|
|
136
|
+
question: 'Which?',
|
|
137
|
+
options: ['A', 'B'],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const data = JSON.parse(result.content[1].text) as {
|
|
141
|
+
isTie: boolean;
|
|
142
|
+
winner: string | null;
|
|
143
|
+
consensusLevel: string;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
expect(data.isTie).toBe(true);
|
|
147
|
+
expect(data.consensusLevel).toBe('split');
|
|
148
|
+
// Tie-break by confidence: B has 90 vs A has 60
|
|
149
|
+
expect(data.winner).toBe('B');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should not include rawResponse in JSON votes', async () => {
|
|
153
|
+
mockCreate.mockResolvedValueOnce({
|
|
154
|
+
choices: [{ message: { content: '{"choice": "A", "confidence": 80}' }, finish_reason: 'stop' }],
|
|
155
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
156
|
+
model: 'gpt-4',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const result = await duckVoteTool(mockProviderManager, {
|
|
160
|
+
question: 'Test?',
|
|
161
|
+
options: ['A', 'B'],
|
|
162
|
+
voters: ['openai'],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const data = JSON.parse(result.content[1].text) as {
|
|
166
|
+
votes: Record<string, unknown>[];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// rawResponse should not be in the structured output
|
|
170
|
+
expect(data.votes[0]).not.toHaveProperty('rawResponse');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -62,9 +62,11 @@ describe('getUsageStatsTool', () => {
|
|
|
62
62
|
const result = getUsageStatsTool(usageService, { period: 'today' });
|
|
63
63
|
|
|
64
64
|
expect(result.content).toBeDefined();
|
|
65
|
-
expect(result.content).toHaveLength(
|
|
65
|
+
expect(result.content).toHaveLength(2);
|
|
66
66
|
expect(result.content[0].type).toBe('text');
|
|
67
67
|
expect(typeof result.content[0].text).toBe('string');
|
|
68
|
+
expect(result.content[1].type).toBe('text');
|
|
69
|
+
expect(() => JSON.parse(result.content[1].text)).not.toThrow();
|
|
68
70
|
});
|
|
69
71
|
|
|
70
72
|
it('should include period label in output', () => {
|