onbuzz 3.6.1 → 3.6.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.
Files changed (83) hide show
  1. package/package.json +1 -1
  2. package/src/__test-utils__/fixtures/malformedJson.js +31 -0
  3. package/src/__test-utils__/globalSetup.js +9 -0
  4. package/src/__test-utils__/globalTeardown.js +12 -0
  5. package/src/__test-utils__/mockFactories.js +101 -0
  6. package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -0
  7. package/src/analyzers/__tests__/ConfigValidator.test.js +362 -0
  8. package/src/analyzers/__tests__/ESLintAnalyzer.test.js +271 -0
  9. package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -0
  10. package/src/analyzers/__tests__/PrettierFormatter.test.js +197 -0
  11. package/src/analyzers/__tests__/PythonAnalyzer.test.js +208 -0
  12. package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -0
  13. package/src/analyzers/__tests__/SparrowAnalyzer.test.js +270 -0
  14. package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -0
  15. package/src/core/__tests__/agentPool.test.js +601 -0
  16. package/src/core/__tests__/agentScheduler.test.js +576 -0
  17. package/src/core/__tests__/contextManager.test.js +252 -0
  18. package/src/core/__tests__/flowExecutor.test.js +262 -0
  19. package/src/core/__tests__/messageProcessor.test.js +627 -0
  20. package/src/core/__tests__/orchestrator.test.js +257 -0
  21. package/src/core/__tests__/stateManager.test.js +375 -0
  22. package/src/core/agentPool.js +11 -1
  23. package/src/index.js +25 -9
  24. package/src/interfaces/terminal/__tests__/smoke/imports.test.js +3 -5
  25. package/src/services/__tests__/agentActivityService.test.js +319 -0
  26. package/src/services/__tests__/apiKeyManager.test.js +206 -0
  27. package/src/services/__tests__/benchmarkService.test.js +184 -0
  28. package/src/services/__tests__/budgetService.test.js +211 -0
  29. package/src/services/__tests__/contextInjectionService.test.js +205 -0
  30. package/src/services/__tests__/conversationCompactionService.test.js +280 -0
  31. package/src/services/__tests__/credentialVault.test.js +469 -0
  32. package/src/services/__tests__/errorHandler.test.js +314 -0
  33. package/src/services/__tests__/fileAttachmentService.test.js +278 -0
  34. package/src/services/__tests__/flowContextService.test.js +199 -0
  35. package/src/services/__tests__/memoryService.test.js +450 -0
  36. package/src/services/__tests__/modelRouterService.test.js +388 -0
  37. package/src/services/__tests__/modelsService.test.js +261 -0
  38. package/src/services/__tests__/portRegistry.test.js +123 -0
  39. package/src/services/__tests__/projectDetector.test.js +34 -0
  40. package/src/services/__tests__/promptService.test.js +242 -0
  41. package/src/services/__tests__/qualityInspector.test.js +97 -0
  42. package/src/services/__tests__/scheduleService.test.js +308 -0
  43. package/src/services/__tests__/serviceRegistry.test.js +74 -0
  44. package/src/services/__tests__/skillsService.test.js +402 -0
  45. package/src/services/__tests__/tokenCountingService.test.js +48 -0
  46. package/src/tools/__tests__/agentCommunicationTool.test.js +500 -0
  47. package/src/tools/__tests__/agentDelayTool.test.js +342 -0
  48. package/src/tools/__tests__/asyncToolManager.test.js +344 -0
  49. package/src/tools/__tests__/baseTool.test.js +420 -0
  50. package/src/tools/__tests__/codeMapTool.test.js +348 -0
  51. package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -0
  52. package/src/tools/__tests__/fileTreeTool.test.js +274 -0
  53. package/src/tools/__tests__/filesystemTool.test.js +717 -0
  54. package/src/tools/__tests__/helpTool.test.js +204 -0
  55. package/src/tools/__tests__/jobDoneTool.test.js +296 -0
  56. package/src/tools/__tests__/memoryTool.test.js +297 -0
  57. package/src/tools/__tests__/seekTool.test.js +282 -0
  58. package/src/tools/__tests__/skillsTool.test.js +226 -0
  59. package/src/tools/__tests__/staticAnalysisTool.test.js +509 -0
  60. package/src/tools/__tests__/taskManagerTool.test.js +725 -0
  61. package/src/tools/__tests__/terminalTool.test.js +384 -0
  62. package/src/tools/__tests__/userPromptTool.test.js +297 -0
  63. package/src/tools/__tests__/webTool.e2e.test.js +25 -11
  64. package/src/tools/webTool.js +6 -12
  65. package/src/types/__tests__/agent.test.js +499 -0
  66. package/src/types/__tests__/contextReference.test.js +606 -0
  67. package/src/types/__tests__/conversation.test.js +555 -0
  68. package/src/types/__tests__/toolCommand.test.js +584 -0
  69. package/src/types/contextReference.js +1 -1
  70. package/src/utilities/__tests__/attachmentValidator.test.js +80 -0
  71. package/src/utilities/__tests__/configManager.test.js +397 -0
  72. package/src/utilities/__tests__/constants.test.js +49 -0
  73. package/src/utilities/__tests__/directoryAccessManager.test.js +388 -0
  74. package/src/utilities/__tests__/fileProcessor.test.js +104 -0
  75. package/src/utilities/__tests__/jsonRepair.test.js +104 -0
  76. package/src/utilities/__tests__/logger.test.js +129 -0
  77. package/src/utilities/__tests__/platformUtils.test.js +87 -0
  78. package/src/utilities/__tests__/structuredFileValidator.test.js +263 -0
  79. package/src/utilities/__tests__/tagParser.test.js +887 -0
  80. package/src/utilities/__tests__/toolConstants.test.js +94 -0
  81. package/src/utilities/tagParser.js +2 -2
  82. package/src/tools/browserTool.js +0 -897
  83. package/src/utilities/platformUtils.test.js +0 -98
@@ -0,0 +1,388 @@
1
+ import { jest, describe, test, expect, beforeEach } from '@jest/globals';
2
+ import { createMockLogger, createMockConfig } from '../../__test-utils__/mockFactories.js';
3
+
4
+ // Mock constants
5
+ jest.unstable_mockModule('../../utilities/constants.js', () => ({
6
+ MODEL_ROUTER_CONFIG: {
7
+ ROUTER_MODEL: 'autopilot-model-router',
8
+ CONTEXT_MESSAGES_COUNT: 5,
9
+ REQUEST_TIMEOUT: 10000,
10
+ MAX_ROUTING_STRATEGY_LENGTH: 2000
11
+ },
12
+ HTTP_STATUS: { OK: 200, INTERNAL_SERVER_ERROR: 500 },
13
+ MODELS: {
14
+ ANTHROPIC_SONNET: 'anthropic-sonnet',
15
+ ANTHROPIC_OPUS: 'anthropic-opus',
16
+ GPT_4: 'gpt-4',
17
+ GPT_5_1_CODEX_MINI: 'gpt-5.1-codex-mini',
18
+ DEEPSEEK_R1: 'deepseek-r1'
19
+ }
20
+ }));
21
+
22
+ const { default: ModelRouterService } = await import('../../services/modelRouterService.js');
23
+
24
+ describe('ModelRouterService', () => {
25
+ let service;
26
+ let logger;
27
+ let config;
28
+ let mockBenchmarkService;
29
+ let mockAiService;
30
+
31
+ beforeEach(() => {
32
+ logger = createMockLogger();
33
+ config = createMockConfig();
34
+ mockBenchmarkService = {
35
+ getBenchmarkTable: jest.fn().mockReturnValue('Model benchmarks table data'),
36
+ getStatus: jest.fn().mockReturnValue({ loaded: true })
37
+ };
38
+ mockAiService = {
39
+ sendMessage: jest.fn()
40
+ };
41
+ service = new ModelRouterService(config, logger, mockBenchmarkService, mockAiService);
42
+ jest.clearAllMocks();
43
+ });
44
+
45
+ // ── Constructor ──
46
+ test('constructor initializes with dependencies', () => {
47
+ expect(service.config).toBe(config);
48
+ expect(service.logger).toBe(logger);
49
+ expect(service.benchmarkService).toBe(mockBenchmarkService);
50
+ expect(service.aiService).toBe(mockAiService);
51
+ expect(service.routerModel).toBe('autopilot-model-router');
52
+ expect(service.contextMessagesCount).toBe(5);
53
+ expect(service.requestTimeout).toBe(10000);
54
+ });
55
+
56
+ // ── routeMessage ──
57
+ test('routeMessage returns routing result with selected model', async () => {
58
+ mockAiService.sendMessage.mockResolvedValue({
59
+ content: JSON.stringify({
60
+ selectedModel: 'gpt-4',
61
+ taskType: 'coding',
62
+ confidence: 0.9,
63
+ reasoning: 'Complex coding task',
64
+ factors: ['code complexity']
65
+ })
66
+ });
67
+
68
+ const result = await service.routeMessage(
69
+ { content: 'Fix this bug in my code', role: 'user' },
70
+ [],
71
+ 'anthropic-sonnet',
72
+ ['anthropic-sonnet', 'gpt-4', 'deepseek-r1']
73
+ );
74
+
75
+ expect(result.selectedModel).toBe('gpt-4');
76
+ expect(result.previousModel).toBe('anthropic-sonnet');
77
+ expect(result.changed).toBe(true);
78
+ expect(result.reasoning).toContain('Complex coding');
79
+ });
80
+
81
+ test('routeMessage falls back to current model on error', async () => {
82
+ mockAiService.sendMessage.mockRejectedValue(new Error('API timeout'));
83
+
84
+ const result = await service.routeMessage(
85
+ { content: 'test' },
86
+ [],
87
+ 'anthropic-sonnet',
88
+ ['anthropic-sonnet', 'gpt-4']
89
+ );
90
+
91
+ expect(result.selectedModel).toBe('anthropic-sonnet');
92
+ expect(result.changed).toBe(false);
93
+ expect(result.reasoning).toContain('fallback');
94
+ });
95
+
96
+ test('routeMessage falls back to first available model when no current', async () => {
97
+ mockAiService.sendMessage.mockRejectedValue(new Error('error'));
98
+
99
+ const result = await service.routeMessage(
100
+ { content: 'test' },
101
+ [],
102
+ '',
103
+ ['gpt-4', 'deepseek-r1']
104
+ );
105
+
106
+ expect(result.selectedModel).toBe('gpt-4');
107
+ });
108
+
109
+ test('routeMessage falls back to ANTHROPIC_SONNET when nothing available', async () => {
110
+ mockAiService.sendMessage.mockRejectedValue(new Error('error'));
111
+
112
+ const result = await service.routeMessage(
113
+ { content: 'test' },
114
+ [],
115
+ '',
116
+ []
117
+ );
118
+
119
+ expect(result.selectedModel).toBe('anthropic-sonnet');
120
+ });
121
+
122
+ test('routeMessage handles unchanged model', async () => {
123
+ mockAiService.sendMessage.mockResolvedValue({
124
+ content: JSON.stringify({
125
+ selectedModel: 'anthropic-sonnet',
126
+ taskType: 'quick-tasks',
127
+ confidence: 0.8,
128
+ reasoning: 'Current model is suitable'
129
+ })
130
+ });
131
+
132
+ const result = await service.routeMessage(
133
+ { content: 'Hello' },
134
+ [],
135
+ 'anthropic-sonnet',
136
+ ['anthropic-sonnet', 'gpt-4']
137
+ );
138
+
139
+ expect(result.selectedModel).toBe('anthropic-sonnet');
140
+ expect(result.changed).toBe(false);
141
+ });
142
+
143
+ // ── _buildRoutingContext ──
144
+ test('_buildRoutingContext builds context from message and history', () => {
145
+ const message = {
146
+ content: 'Fix this code',
147
+ role: 'user',
148
+ contextReferences: [{ type: 'file' }]
149
+ };
150
+ const recentMessages = [
151
+ { role: 'user', content: 'Previous message', timestamp: '2024-01-01' },
152
+ { role: 'assistant', content: 'Previous response', timestamp: '2024-01-01' }
153
+ ];
154
+
155
+ const context = service._buildRoutingContext(message, recentMessages, 'anthropic-sonnet', ['anthropic-sonnet', 'gpt-4']);
156
+
157
+ expect(context.currentMessage.content).toBe('Fix this code');
158
+ expect(context.currentMessage.hasContextReferences).toBe(true);
159
+ expect(context.currentMessage.contextTypes).toEqual(['file']);
160
+ expect(context.recentMessages.length).toBe(2);
161
+ expect(context.currentModel).toBe('anthropic-sonnet');
162
+ expect(context.availableModels.length).toBe(2);
163
+ expect(context.messageCount).toBe(3);
164
+ });
165
+
166
+ test('_buildRoutingContext truncates long messages', () => {
167
+ const longContent = 'x'.repeat(2000);
168
+ const message = { content: longContent, role: 'user' };
169
+
170
+ const context = service._buildRoutingContext(message, [], 'anthropic-sonnet', []);
171
+ expect(context.currentMessage.content.length).toBe(1000);
172
+ });
173
+
174
+ test('_buildRoutingContext limits recent messages count', () => {
175
+ const manyMessages = Array.from({ length: 20 }, (_, i) => ({
176
+ role: 'user',
177
+ content: `Message ${i}`,
178
+ timestamp: '2024-01-01'
179
+ }));
180
+
181
+ const context = service._buildRoutingContext(
182
+ { content: 'current' },
183
+ manyMessages,
184
+ 'anthropic-sonnet',
185
+ []
186
+ );
187
+
188
+ expect(context.recentMessages.length).toBe(5); // contextMessagesCount
189
+ });
190
+
191
+ test('_buildRoutingContext handles object models with pricing', () => {
192
+ const models = [
193
+ { id: 'gpt-4', pricing: { input: 0.01, output: 0.03 } },
194
+ 'anthropic-sonnet'
195
+ ];
196
+
197
+ const context = service._buildRoutingContext(
198
+ { content: 'test' },
199
+ [],
200
+ 'gpt-4',
201
+ models
202
+ );
203
+
204
+ expect(context.availableModels[0].name).toBe('gpt-4');
205
+ expect(context.availableModels[0].pricing).toBeDefined();
206
+ expect(context.availableModels[0].isCurrentModel).toBe(true);
207
+ expect(context.availableModels[1].name).toBe('anthropic-sonnet');
208
+ });
209
+
210
+ test('_buildRoutingContext handles missing contextReferences', () => {
211
+ const context = service._buildRoutingContext({ content: 'test' }, [], '', []);
212
+ expect(context.currentMessage.hasContextReferences).toBe(false);
213
+ expect(context.currentMessage.contextTypes).toEqual([]);
214
+ });
215
+
216
+ // ── _parseRoutingResponse ──
217
+ test('_parseRoutingResponse parses valid JSON', () => {
218
+ const content = JSON.stringify({
219
+ selectedModel: 'gpt-4',
220
+ taskType: 'coding',
221
+ confidence: 0.9,
222
+ reasoning: 'Good for coding',
223
+ factors: ['complexity']
224
+ });
225
+
226
+ const result = service._parseRoutingResponse(content);
227
+ expect(result.selectedModel).toBe('gpt-4');
228
+ expect(result.taskType).toBe('coding');
229
+ expect(result.confidence).toBe(0.9);
230
+ });
231
+
232
+ test('_parseRoutingResponse extracts JSON from surrounding text', () => {
233
+ const content = 'Here is my analysis:\n{"selectedModel": "gpt-4", "taskType": "analysis"}\nDone.';
234
+ const result = service._parseRoutingResponse(content);
235
+ expect(result.selectedModel).toBe('gpt-4');
236
+ });
237
+
238
+ test('_parseRoutingResponse returns null model on missing JSON', () => {
239
+ const result = service._parseRoutingResponse('No JSON here at all');
240
+ expect(result.selectedModel).toBeNull();
241
+ expect(result.confidence).toBe(0.0);
242
+ expect(result.factors).toContain('parsing-error');
243
+ });
244
+
245
+ test('_parseRoutingResponse returns null model on missing selectedModel', () => {
246
+ const content = JSON.stringify({ taskType: 'coding' });
247
+ const result = service._parseRoutingResponse(content);
248
+ expect(result.selectedModel).toBeNull();
249
+ });
250
+
251
+ test('_parseRoutingResponse fills defaults for missing fields', () => {
252
+ const content = JSON.stringify({ selectedModel: 'gpt-4' });
253
+ const result = service._parseRoutingResponse(content);
254
+ expect(result.taskType).toBe('unknown');
255
+ expect(result.confidence).toBe(0.5);
256
+ expect(result.reasoning).toBe('No reasoning provided');
257
+ expect(result.factors).toEqual([]);
258
+ });
259
+
260
+ // ── _validateModelSelection ──
261
+ test('_validateModelSelection returns selected model when available', () => {
262
+ const decision = { selectedModel: 'gpt-4' };
263
+ const result = service._validateModelSelection(decision, ['anthropic-sonnet', 'gpt-4'], 'anthropic-sonnet');
264
+ expect(result).toBe('gpt-4');
265
+ });
266
+
267
+ test('_validateModelSelection returns current model when selected is unavailable', () => {
268
+ const decision = { selectedModel: 'unknown-model' };
269
+ const result = service._validateModelSelection(decision, ['anthropic-sonnet', 'gpt-4'], 'anthropic-sonnet');
270
+ expect(result).toBe('anthropic-sonnet');
271
+ });
272
+
273
+ test('_validateModelSelection returns current model when no model selected', () => {
274
+ const decision = { selectedModel: null };
275
+ const result = service._validateModelSelection(decision, ['anthropic-sonnet'], 'anthropic-sonnet');
276
+ expect(result).toBe('anthropic-sonnet');
277
+ });
278
+
279
+ test('_validateModelSelection handles object model arrays', () => {
280
+ const decision = { selectedModel: 'gpt-4' };
281
+ const models = [{ id: 'anthropic-sonnet' }, { name: 'gpt-4' }];
282
+ const result = service._validateModelSelection(decision, models, 'anthropic-sonnet');
283
+ expect(result).toBe('gpt-4');
284
+ });
285
+
286
+ // ── _formatRecentMessages ──
287
+ test('_formatRecentMessages formats message list', () => {
288
+ const messages = [
289
+ { role: 'user', content: 'Hello' },
290
+ { role: 'assistant', content: 'Hi there' }
291
+ ];
292
+ const result = service._formatRecentMessages(messages);
293
+ expect(result).toContain('**user**');
294
+ expect(result).toContain('**assistant**');
295
+ });
296
+
297
+ test('_formatRecentMessages returns message for empty list', () => {
298
+ const result = service._formatRecentMessages([]);
299
+ expect(result).toContain('No recent messages');
300
+ });
301
+
302
+ // ── _formatBenchmarkData ──
303
+ test('_formatBenchmarkData returns text as-is', () => {
304
+ expect(service._formatBenchmarkData('benchmark data')).toBe('benchmark data');
305
+ });
306
+
307
+ test('_formatBenchmarkData returns fallback for null', () => {
308
+ expect(service._formatBenchmarkData(null)).toContain('No benchmark data');
309
+ });
310
+
311
+ // ── _createRoutingPrompt ──
312
+ test('_createRoutingPrompt generates prompt with context', () => {
313
+ const context = {
314
+ currentModel: 'anthropic-sonnet',
315
+ messageCount: 3,
316
+ availableModels: [{ name: 'anthropic-sonnet', isCurrentModel: true }],
317
+ recentMessages: [],
318
+ currentMessage: { role: 'user', content: 'test', hasContextReferences: false, contextTypes: [] }
319
+ };
320
+
321
+ const prompt = service._createRoutingPrompt(context, 'benchmark data', null);
322
+ expect(prompt).toContain('anthropic-sonnet');
323
+ expect(prompt).toContain('benchmark data');
324
+ expect(prompt).toContain('selectedModel');
325
+ });
326
+
327
+ test('_createRoutingPrompt includes routing strategy when provided', () => {
328
+ const context = {
329
+ currentModel: 'test',
330
+ messageCount: 1,
331
+ availableModels: [],
332
+ recentMessages: [],
333
+ currentMessage: { role: 'user', content: 'test', hasContextReferences: false, contextTypes: [] }
334
+ };
335
+
336
+ const prompt = service._createRoutingPrompt(context, null, 'Use cheapest model for simple tasks');
337
+ expect(prompt).toContain('Use cheapest model for simple tasks');
338
+ expect(prompt).toContain('Agent-Specific Routing Strategy');
339
+ });
340
+
341
+ test('_createRoutingPrompt includes pricing info for models', () => {
342
+ const context = {
343
+ currentModel: 'test',
344
+ messageCount: 1,
345
+ availableModels: [{ name: 'gpt-4', isCurrentModel: false, pricing: { input: 0.01, output: 0.03 } }],
346
+ recentMessages: [],
347
+ currentMessage: { role: 'user', content: 'test', hasContextReferences: false, contextTypes: [] }
348
+ };
349
+
350
+ const prompt = service._createRoutingPrompt(context, null, null);
351
+ expect(prompt).toContain('$0.01');
352
+ expect(prompt).toContain('$0.03');
353
+ });
354
+
355
+ // ── getStatus ──
356
+ test('getStatus returns service status', () => {
357
+ const status = service.getStatus();
358
+ expect(status.routerModel).toBe('autopilot-model-router');
359
+ expect(status.contextMessagesCount).toBe(5);
360
+ expect(status.requestTimeout).toBe(10000);
361
+ expect(status.isAvailable).toBe(true);
362
+ expect(status.benchmarkServiceStatus).toBeDefined();
363
+ });
364
+
365
+ // ── testRouter ──
366
+ test('testRouter returns success when routing works', async () => {
367
+ mockAiService.sendMessage.mockResolvedValue({
368
+ content: JSON.stringify({
369
+ selectedModel: 'anthropic-sonnet',
370
+ taskType: 'coding',
371
+ confidence: 0.9,
372
+ reasoning: 'Test routing'
373
+ })
374
+ });
375
+
376
+ const result = await service.testRouter();
377
+ expect(result.success).toBe(true);
378
+ expect(result.selectedModel).toBeDefined();
379
+ });
380
+
381
+ test('testRouter returns failure on error', async () => {
382
+ mockAiService.sendMessage.mockRejectedValue(new Error('API error'));
383
+
384
+ const result = await service.testRouter();
385
+ // testRouter catches the error from routeMessage fallback
386
+ expect(result.success).toBe(true); // routeMessage itself doesn't throw, returns fallback
387
+ });
388
+ });
@@ -0,0 +1,261 @@
1
+ import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';
2
+ import { createMockLogger } from '../../__test-utils__/mockFactories.js';
3
+
4
+ const { default: ModelsService } = await import('../modelsService.js');
5
+
6
+ describe('ModelsService', () => {
7
+ let service;
8
+ let logger;
9
+ let originalFetch;
10
+
11
+ beforeEach(() => {
12
+ jest.useFakeTimers();
13
+ logger = createMockLogger();
14
+ service = new ModelsService({ backend: { baseUrl: 'https://test-api.example.com' } }, logger);
15
+ originalFetch = global.fetch;
16
+ global.fetch = jest.fn();
17
+ });
18
+
19
+ afterEach(() => {
20
+ jest.useRealTimers();
21
+ global.fetch = originalFetch;
22
+ if (service.retryTimer) clearTimeout(service.retryTimer);
23
+ });
24
+
25
+ test('constructor sets backend URL from config', () => {
26
+ expect(service.backendUrl).toBe('https://test-api.example.com');
27
+ });
28
+
29
+ test('constructor uses fallback URL', () => {
30
+ const s = new ModelsService({}, logger);
31
+ expect(s.backendUrl).toBe('http://localhost:8080');
32
+ });
33
+
34
+ test('constructor uses backendUrl config fallback', () => {
35
+ const s = new ModelsService({ backendUrl: 'http://custom:9090' }, logger);
36
+ expect(s.backendUrl).toBe('http://custom:9090');
37
+ });
38
+
39
+ test('setApiKeyManager stores the manager', () => {
40
+ const mockManager = { getKeysForRequest: jest.fn() };
41
+ service.setApiKeyManager(mockManager);
42
+ expect(service.apiKeyManager).toBe(mockManager);
43
+ });
44
+
45
+ test('getAvailableModelNames returns empty when no models', () => {
46
+ const names = service.getAvailableModelNames();
47
+ expect(names).toEqual([]);
48
+ expect(logger.error).toHaveBeenCalled();
49
+ });
50
+
51
+ test('getAvailableModelNames returns model names', () => {
52
+ service.models = [{ name: 'model-a' }, { name: 'model-b' }];
53
+ const names = service.getAvailableModelNames();
54
+ expect(names).toEqual(['model-a', 'model-b']);
55
+ });
56
+
57
+ test('getModels returns empty when no models', () => {
58
+ expect(service.getModels()).toEqual([]);
59
+ });
60
+
61
+ test('getModels returns models array', () => {
62
+ service.models = [{ name: 'model-a' }];
63
+ expect(service.getModels()).toHaveLength(1);
64
+ });
65
+
66
+ test('fetchModels skips when already loading', async () => {
67
+ service.isLoading = true;
68
+ await service.fetchModels();
69
+ expect(global.fetch).not.toHaveBeenCalled();
70
+ });
71
+
72
+ test('fetchModels fetches and stores models', async () => {
73
+ global.fetch.mockResolvedValueOnce({
74
+ ok: true,
75
+ json: jest.fn().mockResolvedValue({
76
+ models: [{ name: 'claude-3' }, { name: 'gpt-4' }]
77
+ })
78
+ });
79
+
80
+ await service.fetchModels();
81
+ expect(service.models).toHaveLength(2);
82
+ expect(service.lastFetched).toBeInstanceOf(Date);
83
+ expect(service.isLoading).toBe(false);
84
+ });
85
+
86
+ test('fetchModels uses /api prefix for localhost', async () => {
87
+ service.backendUrl = 'http://localhost:8080';
88
+ global.fetch.mockResolvedValueOnce({
89
+ ok: true,
90
+ json: jest.fn().mockResolvedValue({ models: [] })
91
+ });
92
+
93
+ await service.fetchModels();
94
+ expect(global.fetch.mock.calls[0][0]).toContain('/api/llm/models');
95
+ });
96
+
97
+ test('fetchModels uses no /api prefix for non-localhost', async () => {
98
+ service.backendUrl = 'https://remote.example.com';
99
+ global.fetch.mockResolvedValueOnce({
100
+ ok: true,
101
+ json: jest.fn().mockResolvedValue({ models: [] })
102
+ });
103
+
104
+ await service.fetchModels();
105
+ expect(global.fetch.mock.calls[0][0]).toBe('https://remote.example.com/llm/models');
106
+ });
107
+
108
+ test('fetchModels includes auth header from apiKeyManager', async () => {
109
+ service.apiKeyManager = {
110
+ getKeysForRequest: jest.fn().mockReturnValue({ loxiaApiKey: 'mgr-key' })
111
+ };
112
+ global.fetch.mockResolvedValueOnce({
113
+ ok: true,
114
+ json: jest.fn().mockResolvedValue({ models: [] })
115
+ });
116
+
117
+ await service.fetchModels();
118
+ const headers = global.fetch.mock.calls[0][1].headers;
119
+ expect(headers.Authorization).toBe('Bearer mgr-key');
120
+ });
121
+
122
+ test('fetchModels falls back to context apiKey', async () => {
123
+ global.fetch.mockResolvedValueOnce({
124
+ ok: true,
125
+ json: jest.fn().mockResolvedValue({ models: [] })
126
+ });
127
+
128
+ await service.fetchModels({ apiKey: 'ctx-key' });
129
+ const headers = global.fetch.mock.calls[0][1].headers;
130
+ expect(headers.Authorization).toBe('Bearer ctx-key');
131
+ });
132
+
133
+ test('fetchModels throws on invalid response format', async () => {
134
+ global.fetch.mockResolvedValueOnce({
135
+ ok: true,
136
+ json: jest.fn().mockResolvedValue({ something: 'else' })
137
+ });
138
+
139
+ await expect(service.fetchModels()).rejects.toThrow('Invalid response format');
140
+ });
141
+
142
+ test('fetchModels handles HTTP error', async () => {
143
+ global.fetch.mockResolvedValueOnce({
144
+ ok: false,
145
+ status: 500,
146
+ statusText: 'Server Error'
147
+ });
148
+
149
+ await expect(service.fetchModels()).rejects.toThrow('HTTP 500');
150
+ });
151
+
152
+ test('fetchModels handles AbortError timeout', async () => {
153
+ const abortError = new Error('Aborted');
154
+ abortError.name = 'AbortError';
155
+ global.fetch.mockRejectedValueOnce(abortError);
156
+
157
+ await expect(service.fetchModels()).rejects.toThrow('timeout');
158
+ });
159
+
160
+ test('fetchModels does not rethrow when models already exist', async () => {
161
+ service.models = [{ name: 'existing' }];
162
+ global.fetch.mockResolvedValueOnce({
163
+ ok: false,
164
+ status: 503,
165
+ statusText: 'Unavailable'
166
+ });
167
+
168
+ // Should not throw because models already exist
169
+ await service.fetchModels();
170
+ expect(service.lastError).toContain('503');
171
+ });
172
+
173
+ test('needsRefresh returns true when never fetched', () => {
174
+ expect(service.needsRefresh()).toBe(true);
175
+ });
176
+
177
+ test('needsRefresh returns false shortly after fetch', () => {
178
+ service.lastFetched = new Date();
179
+ expect(service.needsRefresh()).toBe(false);
180
+ });
181
+
182
+ test('refresh calls fetchModels', async () => {
183
+ global.fetch.mockResolvedValueOnce({
184
+ ok: true,
185
+ json: jest.fn().mockResolvedValue({ models: [] })
186
+ });
187
+ await service.refresh();
188
+ expect(global.fetch).toHaveBeenCalled();
189
+ });
190
+
191
+ test('refreshWithContext fetches when needed', async () => {
192
+ service.needsRefresh = true; // truthy
193
+ global.fetch.mockResolvedValueOnce({
194
+ ok: true,
195
+ json: jest.fn().mockResolvedValue({ models: [] })
196
+ });
197
+ await service.refreshWithContext({ apiKey: 'key' });
198
+ expect(global.fetch).toHaveBeenCalled();
199
+ });
200
+
201
+ test('_scheduleRetry does nothing after max retries', () => {
202
+ service.retryAttempts = 3;
203
+ service._scheduleRetry();
204
+ expect(service.retryTimer).toBeNull();
205
+ expect(logger.error).toHaveBeenCalled();
206
+ });
207
+
208
+ test('_scheduleRetry sets timer with exponential backoff', () => {
209
+ service.retryAttempts = 0;
210
+ service._scheduleRetry();
211
+ expect(service.retryTimer).not.toBeNull();
212
+ expect(service.retryAttempts).toBe(1);
213
+ });
214
+
215
+ test('_cancelRetry clears timer', () => {
216
+ service.retryTimer = setTimeout(() => {}, 10000);
217
+ service._cancelRetry();
218
+ expect(service.retryTimer).toBeNull();
219
+ });
220
+
221
+ test('_cancelRetry is no-op with no timer', () => {
222
+ service._cancelRetry();
223
+ // No error
224
+ });
225
+
226
+ test('getStatus returns full status object', () => {
227
+ service.models = [{ name: 'a' }];
228
+ service.lastFetched = new Date('2024-01-01');
229
+ service.lastError = 'some error';
230
+ const status = service.getStatus();
231
+ expect(status.initialized).toBe(true);
232
+ expect(status.modelCount).toBe(1);
233
+ expect(status.hasError).toBe(true);
234
+ expect(status.error).toBe('some error');
235
+ });
236
+
237
+ test('getStatus when no models', () => {
238
+ const status = service.getStatus();
239
+ expect(status.initialized).toBe(false);
240
+ expect(status.modelCount).toBe(0);
241
+ expect(status.lastFetched).toBeNull();
242
+ });
243
+
244
+ test('initialize handles fetch failure and schedules retry', async () => {
245
+ global.fetch.mockRejectedValueOnce(new Error('network fail'));
246
+ await service.initialize();
247
+ expect(service.lastError).toBe('network fail');
248
+ expect(logger.error).toHaveBeenCalled();
249
+ });
250
+
251
+ test('initialize succeeds with valid data', async () => {
252
+ global.fetch.mockResolvedValueOnce({
253
+ ok: true,
254
+ json: jest.fn().mockResolvedValue({
255
+ models: [{ name: 'test-model' }]
256
+ })
257
+ });
258
+ await service.initialize();
259
+ expect(service.models).toHaveLength(1);
260
+ });
261
+ });