mcp-rubber-duck 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +8 -0
- package/dist/providers/enhanced-manager.d.ts +7 -0
- package/dist/providers/enhanced-manager.d.ts.map +1 -1
- package/dist/providers/enhanced-manager.js +36 -0
- package/dist/providers/enhanced-manager.js.map +1 -1
- package/dist/providers/manager.d.ts +1 -0
- package/dist/providers/manager.d.ts.map +1 -1
- package/dist/providers/manager.js +33 -0
- package/dist/providers/manager.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +93 -33
- package/dist/server.js.map +1 -1
- package/dist/services/progress.d.ts +27 -0
- package/dist/services/progress.d.ts.map +1 -0
- package/dist/services/progress.js +50 -0
- package/dist/services/progress.js.map +1 -0
- package/dist/services/task-manager.d.ts +56 -0
- package/dist/services/task-manager.d.ts.map +1 -0
- package/dist/services/task-manager.js +134 -0
- package/dist/services/task-manager.js.map +1 -0
- package/dist/tools/compare-ducks.d.ts +2 -1
- package/dist/tools/compare-ducks.d.ts.map +1 -1
- package/dist/tools/compare-ducks.js +7 -3
- package/dist/tools/compare-ducks.js.map +1 -1
- package/dist/tools/duck-council.d.ts +2 -1
- package/dist/tools/duck-council.d.ts.map +1 -1
- package/dist/tools/duck-council.js +7 -3
- package/dist/tools/duck-council.js.map +1 -1
- package/dist/tools/duck-debate.d.ts +2 -1
- package/dist/tools/duck-debate.d.ts.map +1 -1
- package/dist/tools/duck-debate.js +19 -1
- package/dist/tools/duck-debate.js.map +1 -1
- package/dist/tools/duck-iterate.d.ts +2 -1
- package/dist/tools/duck-iterate.d.ts.map +1 -1
- package/dist/tools/duck-iterate.js +13 -1
- package/dist/tools/duck-iterate.js.map +1 -1
- package/dist/tools/duck-vote.d.ts +2 -1
- package/dist/tools/duck-vote.d.ts.map +1 -1
- package/dist/tools/duck-vote.js +7 -3
- package/dist/tools/duck-vote.js.map +1 -1
- package/package.json +1 -1
- package/src/providers/enhanced-manager.ts +49 -0
- package/src/providers/manager.ts +45 -0
- package/src/server.ts +110 -32
- package/src/services/progress.ts +59 -0
- package/src/services/task-manager.ts +162 -0
- package/src/tools/compare-ducks.ts +14 -3
- package/src/tools/duck-council.ts +15 -4
- package/src/tools/duck-debate.ts +31 -1
- package/src/tools/duck-iterate.ts +20 -1
- package/src/tools/duck-vote.ts +14 -3
- package/tests/duck-debate.test.ts +80 -0
- package/tests/duck-iterate.test.ts +81 -0
- package/tests/duck-vote.test.ts +70 -0
- package/tests/providers.test.ts +121 -0
- package/tests/services/progress.test.ts +137 -0
- package/tests/services/task-manager.test.ts +344 -0
- package/tests/tools/compare-ducks.test.ts +19 -0
- package/tests/tools/duck-council.test.ts +19 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, jest } from '@jest/globals';
|
|
2
|
+
import { createProgressReporter } from '../../src/services/progress.js';
|
|
3
|
+
import type { ProgressReporter } from '../../src/services/progress.js';
|
|
4
|
+
|
|
5
|
+
describe('createProgressReporter', () => {
|
|
6
|
+
it('should return a disabled no-op reporter when progressToken is undefined', () => {
|
|
7
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
8
|
+
const reporter = createProgressReporter(undefined, sendNotification);
|
|
9
|
+
|
|
10
|
+
expect(reporter.enabled).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should not call sendNotification when progressToken is undefined', async () => {
|
|
14
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
15
|
+
const reporter = createProgressReporter(undefined, sendNotification);
|
|
16
|
+
|
|
17
|
+
await reporter.report(1, 10, 'test');
|
|
18
|
+
|
|
19
|
+
expect(sendNotification).not.toHaveBeenCalled();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should return an enabled reporter when progressToken is a string', () => {
|
|
23
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
24
|
+
const reporter = createProgressReporter('token-123', sendNotification);
|
|
25
|
+
|
|
26
|
+
expect(reporter.enabled).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return an enabled reporter when progressToken is a number', () => {
|
|
30
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
31
|
+
const reporter = createProgressReporter(42, sendNotification);
|
|
32
|
+
|
|
33
|
+
expect(reporter.enabled).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should send a progress notification with correct method and params', async () => {
|
|
37
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
38
|
+
const reporter = createProgressReporter('my-token', sendNotification);
|
|
39
|
+
|
|
40
|
+
await reporter.report(3, 10, 'Processing step 3');
|
|
41
|
+
|
|
42
|
+
expect(sendNotification).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(sendNotification).toHaveBeenCalledWith({
|
|
44
|
+
method: 'notifications/progress',
|
|
45
|
+
params: {
|
|
46
|
+
progressToken: 'my-token',
|
|
47
|
+
progress: 3,
|
|
48
|
+
total: 10,
|
|
49
|
+
message: 'Processing step 3',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should omit message field when message is undefined', async () => {
|
|
55
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
56
|
+
const reporter = createProgressReporter('tok', sendNotification);
|
|
57
|
+
|
|
58
|
+
await reporter.report(1, 5);
|
|
59
|
+
|
|
60
|
+
expect(sendNotification).toHaveBeenCalledWith({
|
|
61
|
+
method: 'notifications/progress',
|
|
62
|
+
params: {
|
|
63
|
+
progressToken: 'tok',
|
|
64
|
+
progress: 1,
|
|
65
|
+
total: 5,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should send multiple progress notifications for successive reports', async () => {
|
|
71
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
72
|
+
const reporter = createProgressReporter('tok', sendNotification);
|
|
73
|
+
|
|
74
|
+
await reporter.report(1, 3, 'A done');
|
|
75
|
+
await reporter.report(2, 3, 'B done');
|
|
76
|
+
await reporter.report(3, 3, 'C done');
|
|
77
|
+
|
|
78
|
+
expect(sendNotification).toHaveBeenCalledTimes(3);
|
|
79
|
+
|
|
80
|
+
// Verify increasing progress values
|
|
81
|
+
const calls = sendNotification.mock.calls as unknown as Array<[{ params: { progress: number } }]>;
|
|
82
|
+
expect(calls[0][0].params.progress).toBe(1);
|
|
83
|
+
expect(calls[1][0].params.progress).toBe(2);
|
|
84
|
+
expect(calls[2][0].params.progress).toBe(3);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should use a numeric progressToken correctly', async () => {
|
|
88
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
89
|
+
const reporter = createProgressReporter(99, sendNotification);
|
|
90
|
+
|
|
91
|
+
await reporter.report(1, 1);
|
|
92
|
+
|
|
93
|
+
expect(sendNotification).toHaveBeenCalledWith({
|
|
94
|
+
method: 'notifications/progress',
|
|
95
|
+
params: {
|
|
96
|
+
progressToken: 99,
|
|
97
|
+
progress: 1,
|
|
98
|
+
total: 1,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should swallow sendNotification errors without throwing', async () => {
|
|
104
|
+
const sendNotification = jest.fn<() => Promise<void>>().mockRejectedValue(new Error('client disconnected'));
|
|
105
|
+
const reporter = createProgressReporter('tok', sendNotification);
|
|
106
|
+
|
|
107
|
+
// Should NOT throw — progress is best-effort
|
|
108
|
+
await expect(reporter.report(1, 5, 'step')).resolves.toBeUndefined();
|
|
109
|
+
expect(sendNotification).toHaveBeenCalledTimes(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should continue reporting after a notification error', async () => {
|
|
113
|
+
const sendNotification = jest.fn<() => Promise<void>>()
|
|
114
|
+
.mockRejectedValueOnce(new Error('transient error'))
|
|
115
|
+
.mockResolvedValueOnce(undefined);
|
|
116
|
+
const reporter = createProgressReporter('tok', sendNotification);
|
|
117
|
+
|
|
118
|
+
await reporter.report(1, 2, 'first');
|
|
119
|
+
await reporter.report(2, 2, 'second');
|
|
120
|
+
|
|
121
|
+
expect(sendNotification).toHaveBeenCalledTimes(2);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('ProgressReporter interface', () => {
|
|
126
|
+
it('should be easy to create a mock for testing tools', async () => {
|
|
127
|
+
// This tests the pattern tool tests will use
|
|
128
|
+
const mockReporter: ProgressReporter = {
|
|
129
|
+
enabled: true,
|
|
130
|
+
report: jest.fn<ProgressReporter['report']>().mockResolvedValue(undefined),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await mockReporter.report(1, 3, 'step 1');
|
|
134
|
+
|
|
135
|
+
expect(mockReporter.report).toHaveBeenCalledWith(1, 3, 'step 1');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -189,4 +189,23 @@ describe('compareDucksTool', () => {
|
|
|
189
189
|
// (the code checks latency > 0)
|
|
190
190
|
expect(result.content[0].text).not.toContain('0ms');
|
|
191
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
|
+
});
|
|
192
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
|
{
|