@vibescope/mcp-server 0.2.0 → 0.2.2
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/README.md +60 -7
- package/dist/api-client.d.ts +251 -1
- package/dist/api-client.js +82 -3
- package/dist/handlers/blockers.js +9 -8
- package/dist/handlers/bodies-of-work.js +96 -63
- package/dist/handlers/connectors.d.ts +45 -0
- package/dist/handlers/connectors.js +183 -0
- package/dist/handlers/cost.d.ts +10 -0
- package/dist/handlers/cost.js +112 -50
- package/dist/handlers/decisions.js +32 -19
- package/dist/handlers/deployment.js +144 -122
- package/dist/handlers/discovery.d.ts +7 -0
- package/dist/handlers/discovery.js +96 -7
- package/dist/handlers/fallback.js +29 -23
- package/dist/handlers/file-checkouts.d.ts +20 -0
- package/dist/handlers/file-checkouts.js +133 -0
- package/dist/handlers/findings.d.ts +6 -0
- package/dist/handlers/findings.js +96 -40
- package/dist/handlers/git-issues.js +40 -36
- package/dist/handlers/ideas.js +49 -31
- package/dist/handlers/index.d.ts +3 -0
- package/dist/handlers/index.js +9 -0
- package/dist/handlers/milestones.js +39 -32
- package/dist/handlers/organizations.js +99 -91
- package/dist/handlers/progress.js +24 -13
- package/dist/handlers/project.js +68 -28
- package/dist/handlers/requests.js +18 -14
- package/dist/handlers/roles.d.ts +18 -0
- package/dist/handlers/roles.js +130 -0
- package/dist/handlers/session.js +58 -17
- package/dist/handlers/sprints.js +93 -81
- package/dist/handlers/tasks.d.ts +2 -0
- package/dist/handlers/tasks.js +189 -91
- package/dist/handlers/types.d.ts +64 -2
- package/dist/handlers/types.js +48 -1
- package/dist/handlers/validation.js +21 -17
- package/dist/index.js +7 -2716
- package/dist/token-tracking.d.ts +74 -0
- package/dist/token-tracking.js +122 -0
- package/dist/tools.js +685 -9
- package/dist/utils.d.ts +5 -0
- package/dist/utils.js +17 -0
- package/docs/TOOLS.md +2053 -0
- package/package.json +4 -1
- package/scripts/generate-docs.ts +212 -0
- package/src/api-client.test.ts +718 -0
- package/src/api-client.ts +320 -6
- package/src/handlers/__test-setup__.ts +16 -0
- package/src/handlers/blockers.test.ts +31 -19
- package/src/handlers/blockers.ts +9 -8
- package/src/handlers/bodies-of-work.test.ts +55 -32
- package/src/handlers/bodies-of-work.ts +115 -115
- package/src/handlers/connectors.test.ts +834 -0
- package/src/handlers/connectors.ts +229 -0
- package/src/handlers/cost.test.ts +34 -44
- package/src/handlers/cost.ts +136 -85
- package/src/handlers/decisions.test.ts +37 -27
- package/src/handlers/decisions.ts +35 -30
- package/src/handlers/deployment.ts +180 -208
- package/src/handlers/discovery.test.ts +4 -5
- package/src/handlers/discovery.ts +98 -8
- package/src/handlers/fallback.test.ts +26 -22
- package/src/handlers/fallback.ts +36 -33
- package/src/handlers/file-checkouts.test.ts +670 -0
- package/src/handlers/file-checkouts.ts +165 -0
- package/src/handlers/findings.test.ts +178 -19
- package/src/handlers/findings.ts +112 -74
- package/src/handlers/git-issues.test.ts +51 -43
- package/src/handlers/git-issues.ts +44 -84
- package/src/handlers/ideas.test.ts +28 -23
- package/src/handlers/ideas.ts +61 -59
- package/src/handlers/index.ts +9 -0
- package/src/handlers/milestones.test.ts +33 -28
- package/src/handlers/milestones.ts +52 -50
- package/src/handlers/organizations.test.ts +104 -83
- package/src/handlers/organizations.ts +117 -142
- package/src/handlers/progress.test.ts +20 -14
- package/src/handlers/progress.ts +26 -24
- package/src/handlers/project.test.ts +34 -27
- package/src/handlers/project.ts +95 -63
- package/src/handlers/requests.test.ts +27 -18
- package/src/handlers/requests.ts +21 -17
- package/src/handlers/roles.test.ts +303 -0
- package/src/handlers/roles.ts +208 -0
- package/src/handlers/session.test.ts +47 -0
- package/src/handlers/session.ts +71 -26
- package/src/handlers/sprints.test.ts +71 -50
- package/src/handlers/sprints.ts +113 -146
- package/src/handlers/tasks.test.ts +77 -15
- package/src/handlers/tasks.ts +231 -156
- package/src/handlers/tool-categories.test.ts +66 -0
- package/src/handlers/types.ts +81 -2
- package/src/handlers/validation.test.ts +78 -45
- package/src/handlers/validation.ts +23 -25
- package/src/index.ts +12 -2732
- package/src/token-tracking.test.ts +453 -0
- package/src/token-tracking.ts +164 -0
- package/src/tools.ts +685 -9
- package/src/utils.test.ts +2 -2
- package/src/utils.ts +17 -0
- package/dist/config/tool-categories.d.ts +0 -31
- package/dist/config/tool-categories.js +0 -253
- package/dist/knowledge.d.ts +0 -6
- package/dist/knowledge.js +0 -218
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
estimateTokens,
|
|
4
|
+
createTokenUsage,
|
|
5
|
+
trackTokenUsage,
|
|
6
|
+
setCurrentModel,
|
|
7
|
+
resetTokenUsage,
|
|
8
|
+
getTokenUsageSummary,
|
|
9
|
+
type TokenUsage,
|
|
10
|
+
} from './token-tracking.js';
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// estimateTokens Tests
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
describe('estimateTokens', () => {
|
|
17
|
+
it('should return 1 for empty object', () => {
|
|
18
|
+
// "{}" is 2 chars, ceil(2/4) = 1
|
|
19
|
+
expect(estimateTokens({})).toBe(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should return 1 for empty array', () => {
|
|
23
|
+
// "[]" is 2 chars, ceil(2/4) = 1
|
|
24
|
+
expect(estimateTokens([])).toBe(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return 1 for empty string', () => {
|
|
28
|
+
// '""' is 2 chars, ceil(2/4) = 1
|
|
29
|
+
expect(estimateTokens('')).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should return 1 for null', () => {
|
|
33
|
+
// "null" is 4 chars, ceil(4/4) = 1
|
|
34
|
+
expect(estimateTokens(null)).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should return 1 for boolean', () => {
|
|
38
|
+
// "true" is 4 chars, ceil(4/4) = 1
|
|
39
|
+
expect(estimateTokens(true)).toBe(1);
|
|
40
|
+
// "false" is 5 chars, ceil(5/4) = 2
|
|
41
|
+
expect(estimateTokens(false)).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should estimate tokens for simple object', () => {
|
|
45
|
+
const obj = { name: 'test' };
|
|
46
|
+
// {"name":"test"} is 15 chars, ceil(15/4) = 4
|
|
47
|
+
expect(estimateTokens(obj)).toBe(4);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should estimate tokens for array of strings', () => {
|
|
51
|
+
const arr = ['one', 'two', 'three'];
|
|
52
|
+
// ["one","two","three"] is 21 chars, ceil(21/4) = 6
|
|
53
|
+
expect(estimateTokens(arr)).toBe(6);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should estimate tokens for nested object', () => {
|
|
57
|
+
const obj = {
|
|
58
|
+
user: {
|
|
59
|
+
name: 'John',
|
|
60
|
+
age: 30,
|
|
61
|
+
},
|
|
62
|
+
active: true,
|
|
63
|
+
};
|
|
64
|
+
// Complex object - just verify it returns a reasonable positive number
|
|
65
|
+
const tokens = estimateTokens(obj);
|
|
66
|
+
expect(tokens).toBeGreaterThan(0);
|
|
67
|
+
expect(tokens).toBeLessThan(100); // Sanity check
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should estimate tokens for large object', () => {
|
|
71
|
+
const obj = {
|
|
72
|
+
tasks: Array(100)
|
|
73
|
+
.fill(null)
|
|
74
|
+
.map((_, i) => ({
|
|
75
|
+
id: `task-${i}`,
|
|
76
|
+
title: `Task number ${i}`,
|
|
77
|
+
status: 'pending',
|
|
78
|
+
})),
|
|
79
|
+
};
|
|
80
|
+
const tokens = estimateTokens(obj);
|
|
81
|
+
// Should be a large number for 100 tasks
|
|
82
|
+
expect(tokens).toBeGreaterThan(500);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should handle numbers', () => {
|
|
86
|
+
// "12345" is 5 chars, ceil(5/4) = 2
|
|
87
|
+
expect(estimateTokens(12345)).toBe(2);
|
|
88
|
+
// "3.14159" is 7 chars, ceil(7/4) = 2
|
|
89
|
+
expect(estimateTokens(3.14159)).toBe(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should handle undefined by treating as null', () => {
|
|
93
|
+
// JSON.stringify(undefined) returns undefined, not a string
|
|
94
|
+
// Our function handles this gracefully
|
|
95
|
+
const tokens = estimateTokens(undefined);
|
|
96
|
+
expect(tokens).toBeGreaterThanOrEqual(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should handle circular reference gracefully', () => {
|
|
100
|
+
const obj: Record<string, unknown> = { name: 'test' };
|
|
101
|
+
obj.self = obj; // Create circular reference
|
|
102
|
+
|
|
103
|
+
// Should not throw, should return minimal estimate
|
|
104
|
+
const tokens = estimateTokens(obj);
|
|
105
|
+
expect(tokens).toBe(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should handle objects with toJSON method', () => {
|
|
109
|
+
const obj = {
|
|
110
|
+
data: 'test',
|
|
111
|
+
toJSON() {
|
|
112
|
+
return { serialized: true };
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
// toJSON returns {"serialized":true} which is 18 chars, ceil(18/4) = 5
|
|
116
|
+
expect(estimateTokens(obj)).toBe(5);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// createTokenUsage Tests
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
describe('createTokenUsage', () => {
|
|
125
|
+
it('should create fresh token usage object', () => {
|
|
126
|
+
const usage = createTokenUsage();
|
|
127
|
+
|
|
128
|
+
expect(usage.callCount).toBe(0);
|
|
129
|
+
expect(usage.totalTokens).toBe(0);
|
|
130
|
+
expect(usage.byTool).toEqual({});
|
|
131
|
+
expect(usage.byModel).toEqual({});
|
|
132
|
+
expect(usage.currentModel).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should create independent instances', () => {
|
|
136
|
+
const usage1 = createTokenUsage();
|
|
137
|
+
const usage2 = createTokenUsage();
|
|
138
|
+
|
|
139
|
+
usage1.callCount = 5;
|
|
140
|
+
usage1.byTool['test'] = { calls: 1, tokens: 10 };
|
|
141
|
+
|
|
142
|
+
expect(usage2.callCount).toBe(0);
|
|
143
|
+
expect(usage2.byTool).toEqual({});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ============================================================================
|
|
148
|
+
// trackTokenUsage Tests
|
|
149
|
+
// ============================================================================
|
|
150
|
+
|
|
151
|
+
describe('trackTokenUsage', () => {
|
|
152
|
+
let usage: TokenUsage;
|
|
153
|
+
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
usage = createTokenUsage();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should increment call count', () => {
|
|
159
|
+
trackTokenUsage(usage, 'test_tool', {}, {});
|
|
160
|
+
expect(usage.callCount).toBe(1);
|
|
161
|
+
|
|
162
|
+
trackTokenUsage(usage, 'test_tool', {}, {});
|
|
163
|
+
expect(usage.callCount).toBe(2);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should accumulate total tokens', () => {
|
|
167
|
+
trackTokenUsage(usage, 'tool1', { key: 'value' }, { result: 'ok' });
|
|
168
|
+
const firstTotal = usage.totalTokens;
|
|
169
|
+
expect(firstTotal).toBeGreaterThan(0);
|
|
170
|
+
|
|
171
|
+
trackTokenUsage(usage, 'tool2', { key: 'value' }, { result: 'ok' });
|
|
172
|
+
expect(usage.totalTokens).toBeGreaterThan(firstTotal);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should track by tool name', () => {
|
|
176
|
+
trackTokenUsage(usage, 'add_task', { title: 'Test' }, { success: true });
|
|
177
|
+
trackTokenUsage(usage, 'add_task', { title: 'Test 2' }, { success: true });
|
|
178
|
+
trackTokenUsage(usage, 'complete_task', { id: '123' }, { success: true });
|
|
179
|
+
|
|
180
|
+
expect(usage.byTool['add_task'].calls).toBe(2);
|
|
181
|
+
expect(usage.byTool['complete_task'].calls).toBe(1);
|
|
182
|
+
expect(usage.byTool['add_task'].tokens).toBeGreaterThan(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should track by model when set', () => {
|
|
186
|
+
setCurrentModel(usage, 'opus');
|
|
187
|
+
trackTokenUsage(usage, 'tool1', { data: 'input' }, { data: 'output' });
|
|
188
|
+
|
|
189
|
+
expect(usage.byModel['opus']).toBeDefined();
|
|
190
|
+
expect(usage.byModel['opus'].input).toBeGreaterThan(0);
|
|
191
|
+
expect(usage.byModel['opus'].output).toBeGreaterThan(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should not track by model when not set', () => {
|
|
195
|
+
trackTokenUsage(usage, 'tool1', { data: 'input' }, { data: 'output' });
|
|
196
|
+
|
|
197
|
+
expect(Object.keys(usage.byModel)).toHaveLength(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should track multiple models separately', () => {
|
|
201
|
+
setCurrentModel(usage, 'opus');
|
|
202
|
+
trackTokenUsage(usage, 'tool1', { x: 1 }, { y: 2 });
|
|
203
|
+
|
|
204
|
+
setCurrentModel(usage, 'sonnet');
|
|
205
|
+
trackTokenUsage(usage, 'tool2', { x: 1 }, { y: 2 });
|
|
206
|
+
trackTokenUsage(usage, 'tool3', { x: 1 }, { y: 2 });
|
|
207
|
+
|
|
208
|
+
expect(Object.keys(usage.byModel)).toHaveLength(2);
|
|
209
|
+
expect(usage.byModel['opus']).toBeDefined();
|
|
210
|
+
expect(usage.byModel['sonnet']).toBeDefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should handle empty args and response', () => {
|
|
214
|
+
trackTokenUsage(usage, 'empty_tool', {}, {});
|
|
215
|
+
|
|
216
|
+
expect(usage.callCount).toBe(1);
|
|
217
|
+
expect(usage.totalTokens).toBeGreaterThanOrEqual(2); // At least 2 for {} + {}
|
|
218
|
+
expect(usage.byTool['empty_tool'].calls).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should handle large args and response', () => {
|
|
222
|
+
const largeArgs = { items: Array(1000).fill('item') };
|
|
223
|
+
const largeResponse = { results: Array(1000).fill({ ok: true }) };
|
|
224
|
+
|
|
225
|
+
trackTokenUsage(usage, 'large_tool', largeArgs, largeResponse);
|
|
226
|
+
|
|
227
|
+
expect(usage.totalTokens).toBeGreaterThan(1000);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ============================================================================
|
|
232
|
+
// setCurrentModel Tests
|
|
233
|
+
// ============================================================================
|
|
234
|
+
|
|
235
|
+
describe('setCurrentModel', () => {
|
|
236
|
+
it('should set the current model', () => {
|
|
237
|
+
const usage = createTokenUsage();
|
|
238
|
+
|
|
239
|
+
setCurrentModel(usage, 'opus');
|
|
240
|
+
expect(usage.currentModel).toBe('opus');
|
|
241
|
+
|
|
242
|
+
setCurrentModel(usage, 'sonnet');
|
|
243
|
+
expect(usage.currentModel).toBe('sonnet');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should allow clearing the model with null', () => {
|
|
247
|
+
const usage = createTokenUsage();
|
|
248
|
+
|
|
249
|
+
setCurrentModel(usage, 'opus');
|
|
250
|
+
expect(usage.currentModel).toBe('opus');
|
|
251
|
+
|
|
252
|
+
setCurrentModel(usage, null);
|
|
253
|
+
expect(usage.currentModel).toBeNull();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ============================================================================
|
|
258
|
+
// resetTokenUsage Tests
|
|
259
|
+
// ============================================================================
|
|
260
|
+
|
|
261
|
+
describe('resetTokenUsage', () => {
|
|
262
|
+
it('should reset all tracking data', () => {
|
|
263
|
+
const usage = createTokenUsage();
|
|
264
|
+
|
|
265
|
+
// Add some data
|
|
266
|
+
setCurrentModel(usage, 'opus');
|
|
267
|
+
trackTokenUsage(usage, 'tool1', { a: 1 }, { b: 2 });
|
|
268
|
+
trackTokenUsage(usage, 'tool2', { c: 3 }, { d: 4 });
|
|
269
|
+
|
|
270
|
+
expect(usage.callCount).toBe(2);
|
|
271
|
+
expect(usage.totalTokens).toBeGreaterThan(0);
|
|
272
|
+
expect(Object.keys(usage.byTool)).toHaveLength(2);
|
|
273
|
+
|
|
274
|
+
// Reset
|
|
275
|
+
resetTokenUsage(usage);
|
|
276
|
+
|
|
277
|
+
expect(usage.callCount).toBe(0);
|
|
278
|
+
expect(usage.totalTokens).toBe(0);
|
|
279
|
+
expect(usage.byTool).toEqual({});
|
|
280
|
+
expect(usage.byModel).toEqual({});
|
|
281
|
+
expect(usage.currentModel).toBeNull();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should allow tracking after reset', () => {
|
|
285
|
+
const usage = createTokenUsage();
|
|
286
|
+
|
|
287
|
+
trackTokenUsage(usage, 'tool1', {}, {});
|
|
288
|
+
resetTokenUsage(usage);
|
|
289
|
+
|
|
290
|
+
trackTokenUsage(usage, 'tool2', { x: 1 }, { y: 2 });
|
|
291
|
+
|
|
292
|
+
expect(usage.callCount).toBe(1);
|
|
293
|
+
expect(usage.byTool['tool1']).toBeUndefined();
|
|
294
|
+
expect(usage.byTool['tool2']).toBeDefined();
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ============================================================================
|
|
299
|
+
// getTokenUsageSummary Tests
|
|
300
|
+
// ============================================================================
|
|
301
|
+
|
|
302
|
+
describe('getTokenUsageSummary', () => {
|
|
303
|
+
it('should return empty summary for new usage', () => {
|
|
304
|
+
const usage = createTokenUsage();
|
|
305
|
+
const summary = getTokenUsageSummary(usage);
|
|
306
|
+
|
|
307
|
+
expect(summary.total_calls).toBe(0);
|
|
308
|
+
expect(summary.total_tokens).toBe(0);
|
|
309
|
+
expect(summary.average_tokens_per_call).toBe(0);
|
|
310
|
+
expect(summary.by_tool).toEqual({});
|
|
311
|
+
expect(summary.by_model).toEqual({});
|
|
312
|
+
expect(summary.current_model).toBeNull();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should calculate average tokens per call', () => {
|
|
316
|
+
const usage = createTokenUsage();
|
|
317
|
+
|
|
318
|
+
// Track 3 calls with roughly similar sizes
|
|
319
|
+
trackTokenUsage(usage, 'tool1', { a: 1 }, { b: 2 });
|
|
320
|
+
trackTokenUsage(usage, 'tool1', { a: 1 }, { b: 2 });
|
|
321
|
+
trackTokenUsage(usage, 'tool1', { a: 1 }, { b: 2 });
|
|
322
|
+
|
|
323
|
+
const summary = getTokenUsageSummary(usage);
|
|
324
|
+
|
|
325
|
+
expect(summary.total_calls).toBe(3);
|
|
326
|
+
expect(summary.average_tokens_per_call).toBeGreaterThan(0);
|
|
327
|
+
expect(summary.average_tokens_per_call).toBe(
|
|
328
|
+
Math.round(summary.total_tokens / summary.total_calls)
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should include per-tool averages', () => {
|
|
333
|
+
const usage = createTokenUsage();
|
|
334
|
+
|
|
335
|
+
trackTokenUsage(usage, 'small_tool', { x: 1 }, { y: 2 });
|
|
336
|
+
trackTokenUsage(usage, 'small_tool', { x: 1 }, { y: 2 });
|
|
337
|
+
|
|
338
|
+
const summary = getTokenUsageSummary(usage);
|
|
339
|
+
|
|
340
|
+
expect(summary.by_tool['small_tool'].calls).toBe(2);
|
|
341
|
+
expect(summary.by_tool['small_tool'].avg).toBeGreaterThan(0);
|
|
342
|
+
expect(summary.by_tool['small_tool'].avg).toBe(
|
|
343
|
+
Math.round(summary.by_tool['small_tool'].tokens / summary.by_tool['small_tool'].calls)
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should include model breakdown', () => {
|
|
348
|
+
const usage = createTokenUsage();
|
|
349
|
+
|
|
350
|
+
setCurrentModel(usage, 'opus');
|
|
351
|
+
trackTokenUsage(usage, 'tool1', { a: 1 }, { b: 2 });
|
|
352
|
+
|
|
353
|
+
setCurrentModel(usage, 'haiku');
|
|
354
|
+
trackTokenUsage(usage, 'tool2', { c: 3 }, { d: 4 });
|
|
355
|
+
|
|
356
|
+
const summary = getTokenUsageSummary(usage);
|
|
357
|
+
|
|
358
|
+
expect(summary.by_model['opus']).toBeDefined();
|
|
359
|
+
expect(summary.by_model['haiku']).toBeDefined();
|
|
360
|
+
expect(summary.current_model).toBe('haiku');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('should return a copy of byModel to prevent mutation', () => {
|
|
364
|
+
const usage = createTokenUsage();
|
|
365
|
+
|
|
366
|
+
setCurrentModel(usage, 'opus');
|
|
367
|
+
trackTokenUsage(usage, 'tool1', { a: 1 }, { b: 2 });
|
|
368
|
+
|
|
369
|
+
const summary = getTokenUsageSummary(usage);
|
|
370
|
+
|
|
371
|
+
// Modify the summary
|
|
372
|
+
summary.by_model['opus'].input = 999;
|
|
373
|
+
|
|
374
|
+
// Original should be unchanged
|
|
375
|
+
expect(usage.byModel['opus'].input).not.toBe(999);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// ============================================================================
|
|
380
|
+
// Integration Tests
|
|
381
|
+
// ============================================================================
|
|
382
|
+
|
|
383
|
+
describe('Token Tracking Integration', () => {
|
|
384
|
+
it('should track a realistic session workflow', () => {
|
|
385
|
+
const usage = createTokenUsage();
|
|
386
|
+
|
|
387
|
+
// Agent starts with opus model
|
|
388
|
+
setCurrentModel(usage, 'opus');
|
|
389
|
+
|
|
390
|
+
// Start session
|
|
391
|
+
trackTokenUsage(
|
|
392
|
+
usage,
|
|
393
|
+
'start_work_session',
|
|
394
|
+
{ git_url: 'https://github.com/org/repo', model: 'opus' },
|
|
395
|
+
{
|
|
396
|
+
session_id: '123',
|
|
397
|
+
persona: 'Atlas',
|
|
398
|
+
next_task: { id: 'task-1', title: 'Fix bug' },
|
|
399
|
+
}
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// Update task
|
|
403
|
+
trackTokenUsage(
|
|
404
|
+
usage,
|
|
405
|
+
'update_task',
|
|
406
|
+
{ task_id: 'task-1', status: 'in_progress', progress_percentage: 25 },
|
|
407
|
+
{ success: true }
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
// Complete task
|
|
411
|
+
trackTokenUsage(
|
|
412
|
+
usage,
|
|
413
|
+
'complete_task',
|
|
414
|
+
{ task_id: 'task-1', summary: 'Fixed the bug' },
|
|
415
|
+
{ success: true, next_task: null }
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
const summary = getTokenUsageSummary(usage);
|
|
419
|
+
|
|
420
|
+
expect(summary.total_calls).toBe(3);
|
|
421
|
+
expect(summary.by_tool['start_work_session'].calls).toBe(1);
|
|
422
|
+
expect(summary.by_tool['update_task'].calls).toBe(1);
|
|
423
|
+
expect(summary.by_tool['complete_task'].calls).toBe(1);
|
|
424
|
+
expect(summary.by_model['opus']).toBeDefined();
|
|
425
|
+
expect(summary.by_model['opus'].input).toBeGreaterThan(0);
|
|
426
|
+
expect(summary.by_model['opus'].output).toBeGreaterThan(0);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should handle session reset', () => {
|
|
430
|
+
const usage = createTokenUsage();
|
|
431
|
+
|
|
432
|
+
// First session
|
|
433
|
+
setCurrentModel(usage, 'sonnet');
|
|
434
|
+
trackTokenUsage(usage, 'tool1', {}, {});
|
|
435
|
+
trackTokenUsage(usage, 'tool2', {}, {});
|
|
436
|
+
|
|
437
|
+
const firstSummary = getTokenUsageSummary(usage);
|
|
438
|
+
expect(firstSummary.total_calls).toBe(2);
|
|
439
|
+
|
|
440
|
+
// Reset for new session
|
|
441
|
+
resetTokenUsage(usage);
|
|
442
|
+
|
|
443
|
+
// Second session
|
|
444
|
+
setCurrentModel(usage, 'haiku');
|
|
445
|
+
trackTokenUsage(usage, 'tool3', {}, {});
|
|
446
|
+
|
|
447
|
+
const secondSummary = getTokenUsageSummary(usage);
|
|
448
|
+
expect(secondSummary.total_calls).toBe(1);
|
|
449
|
+
expect(secondSummary.by_tool['tool1']).toBeUndefined();
|
|
450
|
+
expect(secondSummary.by_tool['tool3']).toBeDefined();
|
|
451
|
+
expect(secondSummary.current_model).toBe('haiku');
|
|
452
|
+
});
|
|
453
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Tracking Utilities
|
|
3
|
+
*
|
|
4
|
+
* Functions for estimating and tracking token usage across MCP tool calls.
|
|
5
|
+
* Extracted from index.ts to enable unit testing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Types
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export interface ModelTokens {
|
|
13
|
+
input: number;
|
|
14
|
+
output: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TokenUsage {
|
|
18
|
+
callCount: number;
|
|
19
|
+
totalTokens: number;
|
|
20
|
+
byTool: Record<string, { calls: number; tokens: number }>;
|
|
21
|
+
byModel: Record<string, ModelTokens>;
|
|
22
|
+
currentModel: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Token Estimation
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Estimate tokens from a JSON-serializable object.
|
|
31
|
+
* Uses a rough heuristic of ~4 characters per token.
|
|
32
|
+
*
|
|
33
|
+
* @param obj - Any JSON-serializable object
|
|
34
|
+
* @returns Estimated token count (always >= 1)
|
|
35
|
+
*/
|
|
36
|
+
export function estimateTokens(obj: unknown): number {
|
|
37
|
+
try {
|
|
38
|
+
const json = JSON.stringify(obj);
|
|
39
|
+
return Math.max(1, Math.ceil(json.length / 4));
|
|
40
|
+
} catch {
|
|
41
|
+
// If JSON serialization fails, return a minimal estimate
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// Token Usage Tracking
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a fresh token usage tracker.
|
|
52
|
+
*/
|
|
53
|
+
export function createTokenUsage(): TokenUsage {
|
|
54
|
+
return {
|
|
55
|
+
callCount: 0,
|
|
56
|
+
totalTokens: 0,
|
|
57
|
+
byTool: {},
|
|
58
|
+
byModel: {},
|
|
59
|
+
currentModel: null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Track token usage for a tool call.
|
|
65
|
+
* Updates the usage object in-place with input/output token estimates.
|
|
66
|
+
*
|
|
67
|
+
* @param usage - The token usage object to update
|
|
68
|
+
* @param toolName - Name of the tool being called
|
|
69
|
+
* @param args - Input arguments to the tool
|
|
70
|
+
* @param response - Response from the tool
|
|
71
|
+
*/
|
|
72
|
+
export function trackTokenUsage(
|
|
73
|
+
usage: TokenUsage,
|
|
74
|
+
toolName: string,
|
|
75
|
+
args: unknown,
|
|
76
|
+
response: unknown
|
|
77
|
+
): void {
|
|
78
|
+
const inputTokens = estimateTokens(args);
|
|
79
|
+
const outputTokens = estimateTokens(response);
|
|
80
|
+
const totalTokens = inputTokens + outputTokens;
|
|
81
|
+
|
|
82
|
+
usage.callCount++;
|
|
83
|
+
usage.totalTokens += totalTokens;
|
|
84
|
+
|
|
85
|
+
if (!usage.byTool[toolName]) {
|
|
86
|
+
usage.byTool[toolName] = { calls: 0, tokens: 0 };
|
|
87
|
+
}
|
|
88
|
+
usage.byTool[toolName].calls++;
|
|
89
|
+
usage.byTool[toolName].tokens += totalTokens;
|
|
90
|
+
|
|
91
|
+
// Track by model if a model is set
|
|
92
|
+
const model = usage.currentModel;
|
|
93
|
+
if (model) {
|
|
94
|
+
if (!usage.byModel[model]) {
|
|
95
|
+
usage.byModel[model] = { input: 0, output: 0 };
|
|
96
|
+
}
|
|
97
|
+
usage.byModel[model].input += inputTokens;
|
|
98
|
+
usage.byModel[model].output += outputTokens;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Set the current model for token tracking.
|
|
104
|
+
* Subsequent calls to trackTokenUsage will be attributed to this model.
|
|
105
|
+
*
|
|
106
|
+
* @param usage - The token usage object to update
|
|
107
|
+
* @param model - Model name (e.g., "opus", "sonnet", "haiku")
|
|
108
|
+
*/
|
|
109
|
+
export function setCurrentModel(usage: TokenUsage, model: string | null): void {
|
|
110
|
+
usage.currentModel = model;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Reset token usage tracking to initial state.
|
|
115
|
+
*
|
|
116
|
+
* @param usage - The token usage object to reset
|
|
117
|
+
*/
|
|
118
|
+
export function resetTokenUsage(usage: TokenUsage): void {
|
|
119
|
+
usage.callCount = 0;
|
|
120
|
+
usage.totalTokens = 0;
|
|
121
|
+
usage.byTool = {};
|
|
122
|
+
usage.byModel = {};
|
|
123
|
+
usage.currentModel = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get a summary of token usage for reporting.
|
|
128
|
+
*
|
|
129
|
+
* @param usage - The token usage object
|
|
130
|
+
* @returns Summary object with stats
|
|
131
|
+
*/
|
|
132
|
+
export function getTokenUsageSummary(usage: TokenUsage): {
|
|
133
|
+
total_calls: number;
|
|
134
|
+
total_tokens: number;
|
|
135
|
+
average_tokens_per_call: number;
|
|
136
|
+
by_tool: Record<string, { calls: number; tokens: number; avg: number }>;
|
|
137
|
+
by_model: Record<string, ModelTokens>;
|
|
138
|
+
current_model: string | null;
|
|
139
|
+
} {
|
|
140
|
+
const byTool: Record<string, { calls: number; tokens: number; avg: number }> = {};
|
|
141
|
+
|
|
142
|
+
for (const [tool, stats] of Object.entries(usage.byTool)) {
|
|
143
|
+
byTool[tool] = {
|
|
144
|
+
calls: stats.calls,
|
|
145
|
+
tokens: stats.tokens,
|
|
146
|
+
avg: stats.calls > 0 ? Math.round(stats.tokens / stats.calls) : 0,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Deep copy byModel to prevent mutation
|
|
151
|
+
const byModel: Record<string, ModelTokens> = {};
|
|
152
|
+
for (const [model, tokens] of Object.entries(usage.byModel)) {
|
|
153
|
+
byModel[model] = { input: tokens.input, output: tokens.output };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
total_calls: usage.callCount,
|
|
158
|
+
total_tokens: usage.totalTokens,
|
|
159
|
+
average_tokens_per_call: usage.callCount > 0 ? Math.round(usage.totalTokens / usage.callCount) : 0,
|
|
160
|
+
by_tool: byTool,
|
|
161
|
+
by_model: byModel,
|
|
162
|
+
current_model: usage.currentModel,
|
|
163
|
+
};
|
|
164
|
+
}
|