onbuzz 3.6.1 → 3.6.3

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 (84) 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__/fileSystemTool.test.js +717 -0
  53. package/src/tools/__tests__/fileTreeTool.test.js +274 -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
  84. /package/src/tools/{filesystemTool.js → fileSystemTool.js} +0 -0
@@ -0,0 +1,314 @@
1
+ import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';
2
+ import { createMockLogger, createMockConfig } from '../../__test-utils__/mockFactories.js';
3
+
4
+ // Mock constants before importing the module under test
5
+ const ERROR_TYPES = {
6
+ FILE_NOT_FOUND: 'FILE_NOT_FOUND',
7
+ PERMISSION_DENIED: 'PERMISSION_DENIED',
8
+ OPERATION_TIMEOUT: 'OPERATION_TIMEOUT',
9
+ RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
10
+ AUTHENTICATION_FAILED: 'AUTHENTICATION_FAILED',
11
+ UNKNOWN_ERROR: 'UNKNOWN_ERROR',
12
+ VALIDATION_ERROR: 'VALIDATION_ERROR',
13
+ CONFIGURATION_ERROR: 'CONFIGURATION_ERROR'
14
+ };
15
+
16
+ const HTTP_STATUS = {
17
+ OK: 200,
18
+ BAD_REQUEST: 400,
19
+ UNAUTHORIZED: 401,
20
+ FORBIDDEN: 403,
21
+ NOT_FOUND: 404,
22
+ TOO_MANY_REQUESTS: 429,
23
+ INTERNAL_SERVER_ERROR: 500,
24
+ };
25
+
26
+ jest.unstable_mockModule('../../utilities/constants.js', () => ({
27
+ ERROR_TYPES,
28
+ HTTP_STATUS,
29
+ AGENT_STATUS: {}
30
+ }));
31
+
32
+ const { ErrorHandler } = await import('../errorHandler.js');
33
+
34
+ describe('ErrorHandler', () => {
35
+ let handler;
36
+ let logger;
37
+
38
+ beforeEach(() => {
39
+ jest.useFakeTimers();
40
+ logger = createMockLogger();
41
+ handler = new ErrorHandler(createMockConfig(), logger);
42
+ });
43
+
44
+ afterEach(() => {
45
+ jest.useRealTimers();
46
+ });
47
+
48
+ // --- classifyError ---
49
+
50
+ test('classifyError returns FILE_NOT_FOUND for ENOENT errors', () => {
51
+ const error = new Error('File missing');
52
+ error.code = 'ENOENT';
53
+ const info = handler.classifyError(error);
54
+ expect(info.type).toBe(ERROR_TYPES.FILE_NOT_FOUND);
55
+ expect(info.message).toBe('File missing');
56
+ expect(info.id).toMatch(/^err_/);
57
+ expect(info.timestamp).toBeDefined();
58
+ });
59
+
60
+ test('classifyError returns PERMISSION_DENIED for EACCES errors', () => {
61
+ const error = new Error('Access denied');
62
+ error.code = 'EACCES';
63
+ const info = handler.classifyError(error);
64
+ expect(info.type).toBe(ERROR_TYPES.PERMISSION_DENIED);
65
+ });
66
+
67
+ test('classifyError returns AUTHENTICATION_FAILED for 401 status', () => {
68
+ const error = new Error('Unauthorized');
69
+ error.status = HTTP_STATUS.UNAUTHORIZED;
70
+ const info = handler.classifyError(error);
71
+ expect(info.type).toBe(ERROR_TYPES.AUTHENTICATION_FAILED);
72
+ });
73
+
74
+ test('classifyError returns RATE_LIMIT_EXCEEDED for 429 status', () => {
75
+ const error = new Error('Rate limit');
76
+ error.status = HTTP_STATUS.TOO_MANY_REQUESTS;
77
+ const info = handler.classifyError(error);
78
+ expect(info.type).toBe(ERROR_TYPES.RATE_LIMIT_EXCEEDED);
79
+ });
80
+
81
+ test('classifyError returns OPERATION_TIMEOUT for timeout message', () => {
82
+ const error = new Error('Request timed out');
83
+ const info = handler.classifyError(error);
84
+ expect(info.type).toBe(ERROR_TYPES.OPERATION_TIMEOUT);
85
+ });
86
+
87
+ test('classifyError returns VALIDATION_ERROR for validation message', () => {
88
+ const error = new Error('Input validation failed');
89
+ const info = handler.classifyError(error);
90
+ expect(info.type).toBe(ERROR_TYPES.VALIDATION_ERROR);
91
+ });
92
+
93
+ test('classifyError returns CONFIGURATION_ERROR for config message', () => {
94
+ const error = new Error('Bad configuration');
95
+ const info = handler.classifyError(error);
96
+ expect(info.type).toBe(ERROR_TYPES.CONFIGURATION_ERROR);
97
+ });
98
+
99
+ test('classifyError uses context.operation for additional classification', () => {
100
+ const error = new Error('Something generic');
101
+ const info = handler.classifyError(error, { operation: 'api_request' });
102
+ expect(info.type).toBe(ERROR_TYPES.RATE_LIMIT_EXCEEDED);
103
+ });
104
+
105
+ test('classifyError sets agentId, toolId, operationId from context', () => {
106
+ const error = new Error('fail');
107
+ const info = handler.classifyError(error, { agentId: 'a1', toolId: 't1', operationId: 'op1' });
108
+ expect(info.agentId).toBe('a1');
109
+ expect(info.toolId).toBe('t1');
110
+ expect(info.operationId).toBe('op1');
111
+ });
112
+
113
+ test('classifyError returns UNKNOWN_ERROR for unrecognizable errors', () => {
114
+ const error = new Error('something completely random with no keywords');
115
+ const info = handler.classifyError(error);
116
+ expect(info.type).toBe(ERROR_TYPES.UNKNOWN_ERROR);
117
+ });
118
+
119
+ // --- determineSeverity ---
120
+
121
+ test('determineSeverity returns critical for authentication errors', () => {
122
+ const error = new Error('Auth');
123
+ error.status = HTTP_STATUS.UNAUTHORIZED;
124
+ expect(handler.determineSeverity(error, {})).toBe('critical');
125
+ });
126
+
127
+ test('determineSeverity returns high for rate limit errors', () => {
128
+ const error = new Error('Rate limit');
129
+ error.status = HTTP_STATUS.TOO_MANY_REQUESTS;
130
+ expect(handler.determineSeverity(error, {})).toBe('high');
131
+ });
132
+
133
+ test('determineSeverity returns high when retryCount >= 3', () => {
134
+ const error = new Error('generic random error no keywords');
135
+ expect(handler.determineSeverity(error, { retryCount: 3 })).toBe('high');
136
+ });
137
+
138
+ test('determineSeverity returns medium for agent communication', () => {
139
+ const error = new Error('something random no keywords');
140
+ expect(handler.determineSeverity(error, { agentId: 'a1', operation: 'agent_communication' })).toBe('medium');
141
+ });
142
+
143
+ test('determineSeverity returns low for generic low-priority errors', () => {
144
+ const error = new Error('some problem');
145
+ error.code = 'ENOENT';
146
+ expect(handler.determineSeverity(error, {})).toBe('low');
147
+ });
148
+
149
+ // --- isRecoverable ---
150
+
151
+ test('isRecoverable returns false for AUTHENTICATION_FAILED', () => {
152
+ const info = handler.classifyError(Object.assign(new Error('auth'), { status: HTTP_STATUS.UNAUTHORIZED }));
153
+ expect(info.recoverable).toBe(false);
154
+ });
155
+
156
+ test('isRecoverable returns false when retryCount >= maxRetries', () => {
157
+ const info = { type: ERROR_TYPES.FILE_NOT_FOUND, retryCount: 5, maxRetries: 2, severity: 'low' };
158
+ expect(handler.isRecoverable(info)).toBe(false);
159
+ });
160
+
161
+ test('isRecoverable returns true for transient recoverable errors', () => {
162
+ const info = { type: ERROR_TYPES.OPERATION_TIMEOUT, retryCount: 0, maxRetries: 3, severity: 'high' };
163
+ expect(handler.isRecoverable(info)).toBe(true);
164
+ });
165
+
166
+ // --- calculateRetryDelay ---
167
+
168
+ test('calculateRetryDelay returns increasing delays with exponential backoff', () => {
169
+ const delay0 = handler.calculateRetryDelay({ retryCount: 0 });
170
+ // Base delay = 1000 * 2^0 + jitter(0-1000) = 1000-2000
171
+ expect(delay0).toBeGreaterThanOrEqual(1000);
172
+ expect(delay0).toBeLessThanOrEqual(2000);
173
+ });
174
+
175
+ test('calculateRetryDelay caps at 30000ms', () => {
176
+ const delay = handler.calculateRetryDelay({ retryCount: 20 });
177
+ expect(delay).toBeLessThanOrEqual(30000);
178
+ });
179
+
180
+ // --- subscribe / notifySubscribers ---
181
+
182
+ test('subscribe registers a callback and unsubscribe removes it', () => {
183
+ const cb = jest.fn();
184
+ const unsub = handler.subscribe(cb);
185
+ handler.notifySubscribers({ id: 'err1' }, { success: true });
186
+ expect(cb).toHaveBeenCalledTimes(1);
187
+ expect(cb).toHaveBeenCalledWith({ id: 'err1' }, { success: true });
188
+
189
+ unsub();
190
+ handler.notifySubscribers({ id: 'err2' }, { success: false });
191
+ expect(cb).toHaveBeenCalledTimes(1); // not called again
192
+ });
193
+
194
+ test('notifySubscribers logs error if callback throws', () => {
195
+ handler.subscribe(() => { throw new Error('boom'); });
196
+ handler.notifySubscribers({}, {});
197
+ expect(logger.error).toHaveBeenCalledWith('Error subscriber callback failed', expect.any(Object));
198
+ });
199
+
200
+ // --- getErrorStats / clearErrorStats / updateErrorStats ---
201
+
202
+ test('getErrorStats returns accumulated stats with type and agent counts', () => {
203
+ handler.updateErrorStats({ type: ERROR_TYPES.FILE_NOT_FOUND, agentId: 'a1' });
204
+ handler.updateErrorStats({ type: ERROR_TYPES.FILE_NOT_FOUND });
205
+ handler.updateErrorStats({ type: ERROR_TYPES.OPERATION_TIMEOUT, agentId: 'a1' });
206
+ const stats = handler.getErrorStats();
207
+ expect(stats.totalErrors).toBe(3);
208
+ expect(stats.errorsByType[ERROR_TYPES.FILE_NOT_FOUND]).toBe(2);
209
+ expect(stats.errorsByType[ERROR_TYPES.OPERATION_TIMEOUT]).toBe(1);
210
+ expect(stats.errorsByAgent['a1']).toBe(2);
211
+ });
212
+
213
+ test('clearErrorStats resets all counters and empties queue', () => {
214
+ handler.updateErrorStats({ type: ERROR_TYPES.FILE_NOT_FOUND });
215
+ handler.errorQueue.push({ test: true });
216
+ handler.clearErrorStats();
217
+ const stats = handler.getErrorStats();
218
+ expect(stats.totalErrors).toBe(0);
219
+ expect(stats.queueLength).toBe(0);
220
+ expect(stats.recoveryAttempts).toBe(0);
221
+ expect(stats.successfulRecoveries).toBe(0);
222
+ expect(stats.criticalErrors).toBe(0);
223
+ });
224
+
225
+ test('getErrorStats computes recoverySuccessRate correctly', () => {
226
+ handler.errorStats.recoveryAttempts = 10;
227
+ handler.errorStats.successfulRecoveries = 7;
228
+ const stats = handler.getErrorStats();
229
+ expect(stats.recoverySuccessRate).toBeCloseTo(0.7);
230
+ });
231
+
232
+ // --- handleError full pipeline ---
233
+
234
+ test('handleError classifies, processes, updates stats and notifies subscribers', async () => {
235
+ const cb = jest.fn();
236
+ handler.subscribe(cb);
237
+
238
+ const error = new Error('Request timed out');
239
+ const result = await handler.handleError(error, { retryCount: 0 });
240
+
241
+ expect(result).toBeDefined();
242
+ expect(result.errorType).toBe(ERROR_TYPES.OPERATION_TIMEOUT);
243
+ expect(result.severity).toBe('high');
244
+ expect(result.errorId).toMatch(/^err_/);
245
+ expect(cb).toHaveBeenCalledTimes(1);
246
+
247
+ const stats = handler.getErrorStats();
248
+ expect(stats.totalErrors).toBe(1);
249
+ });
250
+
251
+ test('handleError returns fallback result when internal processing throws', async () => {
252
+ handler.processError = jest.fn().mockRejectedValue(new Error('processing failed'));
253
+
254
+ const result = await handler.handleError(new Error('test'), {});
255
+ expect(result.success).toBe(false);
256
+ expect(result.severity).toBe('critical');
257
+ });
258
+
259
+ // --- shouldRetry ---
260
+
261
+ test('shouldRetry returns false when recovery succeeded', () => {
262
+ const info = { recoverable: true, retryCount: 0, maxRetries: 3 };
263
+ expect(handler.shouldRetry(info, { success: true })).toBe(false);
264
+ });
265
+
266
+ test('shouldRetry returns true when recoverable and recovery failed', () => {
267
+ const info = { recoverable: true, retryCount: 0, maxRetries: 3 };
268
+ expect(handler.shouldRetry(info, { success: false })).toBe(true);
269
+ });
270
+
271
+ test('shouldRetry returns false when not recoverable', () => {
272
+ const info = { recoverable: false, retryCount: 0, maxRetries: 3 };
273
+ expect(handler.shouldRetry(info, null)).toBe(false);
274
+ });
275
+
276
+ // --- getMaxRetries ---
277
+
278
+ test('getMaxRetries returns 5 for RATE_LIMIT_EXCEEDED', () => {
279
+ const error = new Error('rate');
280
+ error.status = HTTP_STATUS.TOO_MANY_REQUESTS;
281
+ expect(handler.getMaxRetries(error, {})).toBe(5);
282
+ });
283
+
284
+ test('getMaxRetries returns 3 for OPERATION_TIMEOUT', () => {
285
+ const error = new Error('request timed out');
286
+ expect(handler.getMaxRetries(error, {})).toBe(3);
287
+ });
288
+
289
+ // --- handleCriticalError / processError ---
290
+
291
+ test('processError handles critical error by calling handleCriticalError', async () => {
292
+ const error = new Error('auth fail');
293
+ error.status = HTTP_STATUS.UNAUTHORIZED;
294
+ const errorInfo = handler.classifyError(error);
295
+ // severity should be critical
296
+ expect(errorInfo.severity).toBe('critical');
297
+
298
+ const spy = jest.spyOn(handler, 'handleCriticalError');
299
+ await handler.processError(errorInfo);
300
+ expect(spy).toHaveBeenCalledWith(errorInfo);
301
+ });
302
+
303
+ test('processError returns failure result when internal error occurs', async () => {
304
+ const errorInfo = {
305
+ id: 'err_test', type: ERROR_TYPES.FILE_NOT_FOUND, severity: 'low',
306
+ message: 'test', recoverable: true, retryCount: 0, maxRetries: 3
307
+ };
308
+ // Force logError to throw
309
+ handler.logError = jest.fn().mockRejectedValue(new Error('log broken'));
310
+ const result = await handler.processError(errorInfo);
311
+ expect(result.success).toBe(false);
312
+ expect(result.severity).toBe('critical');
313
+ });
314
+ });
@@ -0,0 +1,278 @@
1
+ import { jest, describe, test, expect, beforeEach } from '@jest/globals';
2
+ import { createMockLogger } from '../../__test-utils__/mockFactories.js';
3
+
4
+ // Mock dependencies
5
+ const mockFileProcessor = {
6
+ createDirectory: jest.fn().mockResolvedValue(undefined),
7
+ fileExists: jest.fn().mockResolvedValue(true),
8
+ readFile: jest.fn().mockResolvedValue('{}'),
9
+ writeFile: jest.fn().mockResolvedValue(undefined),
10
+ getFileStats: jest.fn().mockResolvedValue({ size: 1024, modified: new Date() }),
11
+ calculateHash: jest.fn().mockResolvedValue('abc123hash'),
12
+ processFile: jest.fn().mockResolvedValue({ content: 'processed content' }),
13
+ estimateTokens: jest.fn().mockReturnValue(100),
14
+ deleteDirectory: jest.fn().mockResolvedValue(undefined)
15
+ };
16
+
17
+ const mockValidator = {
18
+ validate: jest.fn().mockReturnValue({ valid: true, errors: [], warnings: [], sizeLevel: 'small' }),
19
+ getContentType: jest.fn().mockReturnValue('text'),
20
+ getMimeType: jest.fn().mockReturnValue('text/plain')
21
+ };
22
+
23
+ jest.unstable_mockModule('../../utilities/fileProcessor.js', () => ({
24
+ default: jest.fn().mockImplementation(() => mockFileProcessor)
25
+ }));
26
+
27
+ jest.unstable_mockModule('../../utilities/attachmentValidator.js', () => ({
28
+ default: jest.fn().mockImplementation(() => mockValidator)
29
+ }));
30
+
31
+ jest.unstable_mockModule('../../utilities/userDataDir.js', () => ({
32
+ getUserDataPaths: jest.fn(() => ({
33
+ settings: '/fake/settings',
34
+ attachments: '/fake/attachments',
35
+ skills: '/fake/skills'
36
+ })),
37
+ ensureUserDataDirs: jest.fn(async () => {})
38
+ }));
39
+
40
+ jest.unstable_mockModule('crypto', () => ({
41
+ randomUUID: jest.fn(() => 'test-uuid-1234')
42
+ }));
43
+
44
+ const { default: FileAttachmentService } = await import('../fileAttachmentService.js');
45
+
46
+ describe('FileAttachmentService', () => {
47
+ let service;
48
+ let logger;
49
+
50
+ beforeEach(() => {
51
+ jest.clearAllMocks();
52
+ logger = createMockLogger();
53
+ service = new FileAttachmentService({}, logger);
54
+ // Reset index to avoid stale state
55
+ service.index = null;
56
+ });
57
+
58
+ test('constructor initializes with null index', () => {
59
+ expect(service.index).toBeNull();
60
+ });
61
+
62
+ test('initialize creates directory and loads index', async () => {
63
+ mockFileProcessor.fileExists.mockResolvedValueOnce(false);
64
+ mockFileProcessor.writeFile.mockResolvedValue(undefined);
65
+
66
+ await service.initialize();
67
+ expect(mockFileProcessor.createDirectory).toHaveBeenCalled();
68
+ expect(logger.info).toHaveBeenCalled();
69
+ });
70
+
71
+ test('initialize throws on error', async () => {
72
+ mockFileProcessor.createDirectory.mockRejectedValueOnce(new Error('no dir'));
73
+ await expect(service.initialize()).rejects.toThrow('no dir');
74
+ });
75
+
76
+ test('loadIndex creates new index when file does not exist', async () => {
77
+ mockFileProcessor.fileExists.mockResolvedValueOnce(false);
78
+ mockFileProcessor.writeFile.mockResolvedValue(undefined);
79
+
80
+ const index = await service.loadIndex();
81
+ expect(index).toEqual({ attachments: {}, agentRefs: {} });
82
+ });
83
+
84
+ test('loadIndex loads existing index', async () => {
85
+ const existingIndex = { attachments: { f1: {} }, agentRefs: { a1: ['f1'] } };
86
+ mockFileProcessor.fileExists.mockResolvedValueOnce(true);
87
+ mockFileProcessor.readFile.mockResolvedValueOnce(JSON.stringify(existingIndex));
88
+
89
+ const index = await service.loadIndex();
90
+ expect(index.attachments.f1).toBeDefined();
91
+ });
92
+
93
+ test('loadIndex handles read error with default', async () => {
94
+ mockFileProcessor.fileExists.mockRejectedValueOnce(new Error('read fail'));
95
+ const index = await service.loadIndex();
96
+ expect(index).toEqual({ attachments: {}, agentRefs: {} });
97
+ });
98
+
99
+ test('saveIndex writes JSON to file', async () => {
100
+ service.index = { attachments: {}, agentRefs: {} };
101
+ await service.saveIndex();
102
+ expect(mockFileProcessor.writeFile).toHaveBeenCalled();
103
+ });
104
+
105
+ test('uploadFile creates attachment metadata', async () => {
106
+ service.index = { attachments: {}, agentRefs: {} };
107
+ mockFileProcessor.fileExists.mockResolvedValue(true);
108
+ mockFileProcessor.getFileStats.mockResolvedValue({ size: 512, modified: new Date() });
109
+ mockFileProcessor.processFile.mockResolvedValue({ content: 'text content' });
110
+ mockFileProcessor.estimateTokens.mockReturnValue(50);
111
+ mockFileProcessor.writeFile.mockResolvedValue(undefined);
112
+ mockFileProcessor.createDirectory.mockResolvedValue(undefined);
113
+ mockValidator.validate.mockReturnValue({ valid: true, errors: [], warnings: [], sizeLevel: 'small' });
114
+
115
+ const result = await service.uploadFile({
116
+ agentId: 'agent-1',
117
+ filePath: '/path/to/file.txt',
118
+ mode: 'content'
119
+ });
120
+
121
+ expect(result.fileId).toBe('test-uuid-1234');
122
+ expect(result.agentId).toBe('agent-1');
123
+ expect(result.mode).toBe('content');
124
+ expect(service.index.attachments['test-uuid-1234']).toBeDefined();
125
+ });
126
+
127
+ test('uploadFile throws for non-existent file', async () => {
128
+ service.index = { attachments: {}, agentRefs: {} };
129
+ mockFileProcessor.fileExists.mockResolvedValueOnce(false);
130
+
131
+ await expect(service.uploadFile({
132
+ agentId: 'agent-1',
133
+ filePath: '/missing.txt'
134
+ })).rejects.toThrow('File not found');
135
+ });
136
+
137
+ test('uploadFile throws on validation failure', async () => {
138
+ service.index = { attachments: {}, agentRefs: {} };
139
+ mockFileProcessor.fileExists.mockResolvedValueOnce(true);
140
+ mockFileProcessor.getFileStats.mockResolvedValue({ size: 999999999, modified: new Date() });
141
+ mockValidator.validate.mockReturnValue({ valid: false, errors: ['Too large'], warnings: [] });
142
+
143
+ await expect(service.uploadFile({
144
+ agentId: 'agent-1',
145
+ filePath: '/big.txt'
146
+ })).rejects.toThrow('Validation failed');
147
+ });
148
+
149
+ test('uploadFile handles reference mode', async () => {
150
+ service.index = { attachments: {}, agentRefs: {} };
151
+ mockFileProcessor.fileExists.mockResolvedValue(true);
152
+ mockFileProcessor.getFileStats.mockResolvedValue({ size: 512, modified: new Date() });
153
+ mockValidator.validate.mockReturnValue({ valid: true, errors: [], warnings: [], sizeLevel: 'small' });
154
+
155
+ const result = await service.uploadFile({
156
+ agentId: 'agent-1',
157
+ filePath: '/path/ref.js',
158
+ mode: 'reference'
159
+ });
160
+
161
+ expect(result.mode).toBe('reference');
162
+ expect(result.tokenEstimate).toBe(0);
163
+ });
164
+
165
+ test('getAttachments returns filtered attachments', async () => {
166
+ service.index = {
167
+ attachments: { f1: { fileId: 'f1', agentId: 'a1', fileName: 'a.txt', mode: 'content', active: true } },
168
+ agentRefs: { a1: ['f1'] }
169
+ };
170
+ mockFileProcessor.fileExists.mockResolvedValue(true);
171
+ mockFileProcessor.readFile.mockResolvedValue(JSON.stringify({
172
+ fileId: 'f1', agentId: 'a1', active: true, mode: 'content'
173
+ }));
174
+
175
+ const results = await service.getAttachments('a1');
176
+ expect(results).toHaveLength(1);
177
+ });
178
+
179
+ test('getAttachments loads index if null', async () => {
180
+ service.index = null;
181
+ mockFileProcessor.fileExists.mockResolvedValueOnce(false);
182
+ mockFileProcessor.writeFile.mockResolvedValue(undefined);
183
+
184
+ const results = await service.getAttachments('a1');
185
+ expect(results).toEqual([]);
186
+ });
187
+
188
+ test('getAttachment returns null for unknown fileId', async () => {
189
+ service.index = { attachments: {}, agentRefs: {} };
190
+ const result = await service.getAttachment('unknown');
191
+ expect(result).toBeNull();
192
+ });
193
+
194
+ test('getAttachment returns null when metadata file missing', async () => {
195
+ service.index = { attachments: { f1: { fileId: 'f1', agentId: 'a1' } }, agentRefs: {} };
196
+ mockFileProcessor.fileExists.mockResolvedValueOnce(false);
197
+ const result = await service.getAttachment('f1');
198
+ expect(result).toBeNull();
199
+ });
200
+
201
+ test('getAttachmentContent returns null for reference mode', async () => {
202
+ service.index = { attachments: { f1: { fileId: 'f1', agentId: 'a1' } }, agentRefs: {} };
203
+ mockFileProcessor.fileExists.mockResolvedValueOnce(true);
204
+ mockFileProcessor.readFile.mockResolvedValueOnce(JSON.stringify({ mode: 'reference', agentId: 'a1' }));
205
+
206
+ const content = await service.getAttachmentContent('f1');
207
+ expect(content).toBeNull();
208
+ });
209
+
210
+ test('toggleActive flips active state', async () => {
211
+ service.index = { attachments: { f1: { fileId: 'f1', agentId: 'a1', active: true } }, agentRefs: {} };
212
+ mockFileProcessor.fileExists.mockResolvedValue(true);
213
+ mockFileProcessor.readFile.mockResolvedValueOnce(JSON.stringify({
214
+ fileId: 'f1', agentId: 'a1', active: true
215
+ }));
216
+
217
+ const result = await service.toggleActive('f1');
218
+ expect(result.active).toBe(false);
219
+ });
220
+
221
+ test('toggleActive throws for missing attachment', async () => {
222
+ service.index = { attachments: {}, agentRefs: {} };
223
+ await expect(service.toggleActive('unknown')).rejects.toThrow('Attachment not found');
224
+ });
225
+
226
+ test('deleteAttachment removes when no other references', async () => {
227
+ service.index = {
228
+ attachments: { f1: { fileId: 'f1', agentId: 'a1' } },
229
+ agentRefs: { a1: ['f1'] }
230
+ };
231
+ mockFileProcessor.fileExists.mockResolvedValue(true);
232
+ mockFileProcessor.readFile.mockResolvedValueOnce(JSON.stringify({
233
+ fileId: 'f1', agentId: 'a1', referencedBy: ['a1']
234
+ }));
235
+
236
+ const deleted = await service.deleteAttachment('f1', 'a1');
237
+ expect(deleted).toBe(true);
238
+ expect(mockFileProcessor.deleteDirectory).toHaveBeenCalled();
239
+ });
240
+
241
+ test('deleteAttachment derefs when still referenced', async () => {
242
+ service.index = {
243
+ attachments: { f1: { fileId: 'f1', agentId: 'a1' } },
244
+ agentRefs: { a1: ['f1'] }
245
+ };
246
+ mockFileProcessor.fileExists.mockResolvedValue(true);
247
+ mockFileProcessor.readFile.mockResolvedValueOnce(JSON.stringify({
248
+ fileId: 'f1', agentId: 'a1', referencedBy: ['a1', 'a2']
249
+ }));
250
+
251
+ const deleted = await service.deleteAttachment('f1', 'a1');
252
+ expect(deleted).toBe(false);
253
+ });
254
+
255
+ test('getAttachmentPreview returns truncated content', async () => {
256
+ service.index = { attachments: { f1: { fileId: 'f1', agentId: 'a1' } }, agentRefs: {} };
257
+ mockFileProcessor.fileExists.mockResolvedValue(true);
258
+ const metadata = { fileId: 'f1', agentId: 'a1', mode: 'content', contentFileName: 'content.txt' };
259
+ mockFileProcessor.readFile
260
+ .mockResolvedValueOnce(JSON.stringify(metadata)) // getAttachment
261
+ .mockResolvedValueOnce('x'.repeat(2000)); // getAttachmentContent
262
+
263
+ const preview = await service.getAttachmentPreview('f1');
264
+ expect(preview.length).toBe(1003); // 1000 + '...'
265
+ });
266
+
267
+ test('getAttachmentPreview returns image placeholder', async () => {
268
+ service.index = { attachments: { f1: { fileId: 'f1', agentId: 'a1' } }, agentRefs: {} };
269
+ mockFileProcessor.fileExists.mockResolvedValue(true);
270
+ const metadata = { fileId: 'f1', agentId: 'a1', mode: 'content', contentFileName: 'content.base64' };
271
+ mockFileProcessor.readFile
272
+ .mockResolvedValueOnce(JSON.stringify(metadata))
273
+ .mockResolvedValueOnce('data:image/png;base64,abc');
274
+
275
+ const preview = await service.getAttachmentPreview('f1');
276
+ expect(preview).toBe('[Image content - base64 encoded]');
277
+ });
278
+ });