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.
- package/package.json +1 -1
- package/src/__test-utils__/fixtures/malformedJson.js +31 -0
- package/src/__test-utils__/globalSetup.js +9 -0
- package/src/__test-utils__/globalTeardown.js +12 -0
- package/src/__test-utils__/mockFactories.js +101 -0
- package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -0
- package/src/analyzers/__tests__/ConfigValidator.test.js +362 -0
- package/src/analyzers/__tests__/ESLintAnalyzer.test.js +271 -0
- package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -0
- package/src/analyzers/__tests__/PrettierFormatter.test.js +197 -0
- package/src/analyzers/__tests__/PythonAnalyzer.test.js +208 -0
- package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -0
- package/src/analyzers/__tests__/SparrowAnalyzer.test.js +270 -0
- package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -0
- package/src/core/__tests__/agentPool.test.js +601 -0
- package/src/core/__tests__/agentScheduler.test.js +576 -0
- package/src/core/__tests__/contextManager.test.js +252 -0
- package/src/core/__tests__/flowExecutor.test.js +262 -0
- package/src/core/__tests__/messageProcessor.test.js +627 -0
- package/src/core/__tests__/orchestrator.test.js +257 -0
- package/src/core/__tests__/stateManager.test.js +375 -0
- package/src/core/agentPool.js +11 -1
- package/src/index.js +25 -9
- package/src/interfaces/terminal/__tests__/smoke/imports.test.js +3 -5
- package/src/services/__tests__/agentActivityService.test.js +319 -0
- package/src/services/__tests__/apiKeyManager.test.js +206 -0
- package/src/services/__tests__/benchmarkService.test.js +184 -0
- package/src/services/__tests__/budgetService.test.js +211 -0
- package/src/services/__tests__/contextInjectionService.test.js +205 -0
- package/src/services/__tests__/conversationCompactionService.test.js +280 -0
- package/src/services/__tests__/credentialVault.test.js +469 -0
- package/src/services/__tests__/errorHandler.test.js +314 -0
- package/src/services/__tests__/fileAttachmentService.test.js +278 -0
- package/src/services/__tests__/flowContextService.test.js +199 -0
- package/src/services/__tests__/memoryService.test.js +450 -0
- package/src/services/__tests__/modelRouterService.test.js +388 -0
- package/src/services/__tests__/modelsService.test.js +261 -0
- package/src/services/__tests__/portRegistry.test.js +123 -0
- package/src/services/__tests__/projectDetector.test.js +34 -0
- package/src/services/__tests__/promptService.test.js +242 -0
- package/src/services/__tests__/qualityInspector.test.js +97 -0
- package/src/services/__tests__/scheduleService.test.js +308 -0
- package/src/services/__tests__/serviceRegistry.test.js +74 -0
- package/src/services/__tests__/skillsService.test.js +402 -0
- package/src/services/__tests__/tokenCountingService.test.js +48 -0
- package/src/tools/__tests__/agentCommunicationTool.test.js +500 -0
- package/src/tools/__tests__/agentDelayTool.test.js +342 -0
- package/src/tools/__tests__/asyncToolManager.test.js +344 -0
- package/src/tools/__tests__/baseTool.test.js +420 -0
- package/src/tools/__tests__/codeMapTool.test.js +348 -0
- package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -0
- package/src/tools/__tests__/fileTreeTool.test.js +274 -0
- package/src/tools/__tests__/filesystemTool.test.js +717 -0
- package/src/tools/__tests__/helpTool.test.js +204 -0
- package/src/tools/__tests__/jobDoneTool.test.js +296 -0
- package/src/tools/__tests__/memoryTool.test.js +297 -0
- package/src/tools/__tests__/seekTool.test.js +282 -0
- package/src/tools/__tests__/skillsTool.test.js +226 -0
- package/src/tools/__tests__/staticAnalysisTool.test.js +509 -0
- package/src/tools/__tests__/taskManagerTool.test.js +725 -0
- package/src/tools/__tests__/terminalTool.test.js +384 -0
- package/src/tools/__tests__/userPromptTool.test.js +297 -0
- package/src/tools/__tests__/webTool.e2e.test.js +25 -11
- package/src/tools/webTool.js +6 -12
- package/src/types/__tests__/agent.test.js +499 -0
- package/src/types/__tests__/contextReference.test.js +606 -0
- package/src/types/__tests__/conversation.test.js +555 -0
- package/src/types/__tests__/toolCommand.test.js +584 -0
- package/src/types/contextReference.js +1 -1
- package/src/utilities/__tests__/attachmentValidator.test.js +80 -0
- package/src/utilities/__tests__/configManager.test.js +397 -0
- package/src/utilities/__tests__/constants.test.js +49 -0
- package/src/utilities/__tests__/directoryAccessManager.test.js +388 -0
- package/src/utilities/__tests__/fileProcessor.test.js +104 -0
- package/src/utilities/__tests__/jsonRepair.test.js +104 -0
- package/src/utilities/__tests__/logger.test.js +129 -0
- package/src/utilities/__tests__/platformUtils.test.js +87 -0
- package/src/utilities/__tests__/structuredFileValidator.test.js +263 -0
- package/src/utilities/__tests__/tagParser.test.js +887 -0
- package/src/utilities/__tests__/toolConstants.test.js +94 -0
- package/src/utilities/tagParser.js +2 -2
- package/src/tools/browserTool.js +0 -897
- package/src/utilities/platformUtils.test.js +0 -98
package/src/index.js
CHANGED
|
@@ -32,8 +32,6 @@ import { ToolsRegistry } from './tools/baseTool.js';
|
|
|
32
32
|
import AgentDelayTool from './tools/agentDelayTool.js';
|
|
33
33
|
import TerminalTool from './tools/terminalTool.js';
|
|
34
34
|
import FileSystemTool from './tools/fileSystemTool.js';
|
|
35
|
-
// BrowserTool is DEPRECATED - use WebTool instead
|
|
36
|
-
// import BrowserTool from './tools/browserTool.js';
|
|
37
35
|
import JobDoneTool from './tools/jobDoneTool.js';
|
|
38
36
|
import AgentCommunicationTool from './tools/agentCommunicationTool.js';
|
|
39
37
|
import TaskManagerTool from './tools/taskManagerTool.js';
|
|
@@ -486,11 +484,6 @@ class LoxiaApplication {
|
|
|
486
484
|
this.logger.info('ToolsRegistry set for Help Tool');
|
|
487
485
|
}
|
|
488
486
|
|
|
489
|
-
// NOTE: BrowserTool is DEPRECATED as of December 2024
|
|
490
|
-
// Use WebTool (toolId: "web") for all browser automation tasks
|
|
491
|
-
// The Browser tool registration has been removed - WebTool provides
|
|
492
|
-
// equivalent functionality with better architecture (singleton browser instance)
|
|
493
|
-
|
|
494
487
|
// Set AgentPool dependency for AgentDelayTool
|
|
495
488
|
const agentDelayTool = this.toolsRegistry.getTool('agentdelay');
|
|
496
489
|
if (agentDelayTool && typeof agentDelayTool.setAgentPool === 'function') {
|
|
@@ -848,9 +841,9 @@ class LoxiaApplication {
|
|
|
848
841
|
this.logger?.info('Models service retries cancelled');
|
|
849
842
|
}
|
|
850
843
|
|
|
851
|
-
// Close Puppeteer
|
|
844
|
+
// Close Puppeteer browser (webTool) — it holds DevTools ports
|
|
852
845
|
if (this.toolsRegistry) {
|
|
853
|
-
for (const toolId of ['web'
|
|
846
|
+
for (const toolId of ['web']) {
|
|
854
847
|
try {
|
|
855
848
|
const tool = this.toolsRegistry.getTool(toolId);
|
|
856
849
|
if (tool?.cleanup) {
|
|
@@ -863,6 +856,29 @@ class LoxiaApplication {
|
|
|
863
856
|
}
|
|
864
857
|
}
|
|
865
858
|
|
|
859
|
+
// Kill ALL running terminal processes across all agents
|
|
860
|
+
if (this.toolsRegistry) {
|
|
861
|
+
try {
|
|
862
|
+
const terminalTool = this.toolsRegistry.getTool('terminal');
|
|
863
|
+
if (terminalTool?.commandTracker) {
|
|
864
|
+
let killed = 0;
|
|
865
|
+
for (const [cmdId, cmdInfo] of terminalTool.commandTracker) {
|
|
866
|
+
if (cmdInfo.process && cmdInfo.state === 'RUNNING') {
|
|
867
|
+
try {
|
|
868
|
+
cmdInfo.process.kill('SIGTERM');
|
|
869
|
+
killed++;
|
|
870
|
+
} catch { /* already dead */ }
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (killed > 0) {
|
|
874
|
+
this.logger?.info(`Killed ${killed} running terminal process(es) on shutdown`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
} catch (error) {
|
|
878
|
+
this.logger?.warn('Failed to cleanup terminal processes', { error: error.message });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
866
882
|
// Shutdown interfaces (web server, visual editor, WS connections)
|
|
867
883
|
for (const [type, interface_] of this.interfaces) {
|
|
868
884
|
try {
|
|
@@ -92,12 +92,10 @@ describe('Terminal UI Infrastructure - Imports', () => {
|
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
test('useConnection hook can be imported', async () => {
|
|
95
|
-
const
|
|
95
|
+
const mod = await import('../../state/useConnection.js');
|
|
96
96
|
|
|
97
|
-
expect(useConnection).toBeDefined();
|
|
98
|
-
expect(typeof useConnection).toBe('function');
|
|
99
|
-
expect(useConnectionStatus).toBeDefined();
|
|
100
|
-
expect(typeof useConnectionStatus).toBe('function');
|
|
97
|
+
expect(mod.useConnection).toBeDefined();
|
|
98
|
+
expect(typeof mod.useConnection).toBe('function');
|
|
101
99
|
});
|
|
102
100
|
});
|
|
103
101
|
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
|
|
2
|
+
import { createMockLogger } from '../../__test-utils__/mockFactories.js';
|
|
3
|
+
import {
|
|
4
|
+
shouldAgentBeActive,
|
|
5
|
+
getActiveAgents,
|
|
6
|
+
getAllAgentActivityStatus,
|
|
7
|
+
shouldSkipIteration,
|
|
8
|
+
hasPendingTasks,
|
|
9
|
+
getMessageQueueStatus,
|
|
10
|
+
isAgentDelayed,
|
|
11
|
+
isAgentPaused,
|
|
12
|
+
isExecutingTools
|
|
13
|
+
} from '../agentActivityService.js';
|
|
14
|
+
|
|
15
|
+
// Helper to create a base active agent
|
|
16
|
+
function makeAgent(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
id: 'agent-1',
|
|
19
|
+
name: 'Test Agent',
|
|
20
|
+
sessionId: 'session-1',
|
|
21
|
+
status: 'active',
|
|
22
|
+
mode: 'agent',
|
|
23
|
+
taskList: { tasks: [] },
|
|
24
|
+
messageQueues: {},
|
|
25
|
+
delayEndTime: null,
|
|
26
|
+
pausedUntil: null,
|
|
27
|
+
awaitingUserInput: null,
|
|
28
|
+
stopRequested: false,
|
|
29
|
+
toolExecutionInProgress: false,
|
|
30
|
+
ttl: null,
|
|
31
|
+
...overrides
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('agentActivityService', () => {
|
|
36
|
+
describe('hasPendingTasks', () => {
|
|
37
|
+
test('returns false when taskList is null or missing', () => {
|
|
38
|
+
expect(hasPendingTasks({})).toBe(false);
|
|
39
|
+
expect(hasPendingTasks({ taskList: null })).toBe(false);
|
|
40
|
+
expect(hasPendingTasks({ taskList: {} })).toBe(false);
|
|
41
|
+
expect(hasPendingTasks({ taskList: { tasks: 'not-array' } })).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('returns true when there are pending tasks', () => {
|
|
45
|
+
const agent = makeAgent({
|
|
46
|
+
taskList: { tasks: [{ status: 'pending' }] }
|
|
47
|
+
});
|
|
48
|
+
expect(hasPendingTasks(agent)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('returns true when there are in_progress tasks', () => {
|
|
52
|
+
const agent = makeAgent({
|
|
53
|
+
taskList: { tasks: [{ status: 'completed' }, { status: 'in_progress' }] }
|
|
54
|
+
});
|
|
55
|
+
expect(hasPendingTasks(agent)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('returns false when all tasks are completed', () => {
|
|
59
|
+
const agent = makeAgent({
|
|
60
|
+
taskList: { tasks: [{ status: 'completed' }, { status: 'failed' }] }
|
|
61
|
+
});
|
|
62
|
+
expect(hasPendingTasks(agent)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('getMessageQueueStatus', () => {
|
|
67
|
+
test('returns zero counts for empty queues', () => {
|
|
68
|
+
const result = getMessageQueueStatus({});
|
|
69
|
+
expect(result.hasMessages).toBe(false);
|
|
70
|
+
expect(result.counts.total).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('counts messages across all queues', () => {
|
|
74
|
+
const agent = makeAgent({
|
|
75
|
+
messageQueues: {
|
|
76
|
+
toolResults: [{ id: 1 }],
|
|
77
|
+
interAgentMessages: [{ id: 2 }, { id: 3 }],
|
|
78
|
+
userMessages: [{ id: 4 }]
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
const result = getMessageQueueStatus(agent);
|
|
82
|
+
expect(result.hasMessages).toBe(true);
|
|
83
|
+
expect(result.hasUserMessages).toBe(true);
|
|
84
|
+
expect(result.hasInterAgentMessages).toBe(true);
|
|
85
|
+
expect(result.hasToolResults).toBe(true);
|
|
86
|
+
expect(result.counts.total).toBe(4);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('handles non-array queue values', () => {
|
|
90
|
+
const agent = makeAgent({
|
|
91
|
+
messageQueues: { toolResults: 'not-array', userMessages: null }
|
|
92
|
+
});
|
|
93
|
+
const result = getMessageQueueStatus(agent);
|
|
94
|
+
expect(result.counts.toolResults).toBe(0);
|
|
95
|
+
expect(result.counts.userMessages).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('isAgentDelayed', () => {
|
|
100
|
+
test('returns false when no delayEndTime', () => {
|
|
101
|
+
expect(isAgentDelayed(makeAgent())).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('returns true when delay is in the future', () => {
|
|
105
|
+
const future = new Date(Date.now() + 60000).toISOString();
|
|
106
|
+
expect(isAgentDelayed(makeAgent({ delayEndTime: future }))).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('returns false when delay is in the past', () => {
|
|
110
|
+
const past = new Date(Date.now() - 60000).toISOString();
|
|
111
|
+
expect(isAgentDelayed(makeAgent({ delayEndTime: past }))).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('isAgentPaused', () => {
|
|
116
|
+
test('returns false for active, non-paused agent', () => {
|
|
117
|
+
expect(isAgentPaused(makeAgent())).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('returns true for paused status without expiry', () => {
|
|
121
|
+
expect(isAgentPaused(makeAgent({ status: 'paused' }))).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('returns true for paused status with future expiry', () => {
|
|
125
|
+
const future = new Date(Date.now() + 60000).toISOString();
|
|
126
|
+
expect(isAgentPaused(makeAgent({ status: 'paused', pausedUntil: future }))).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('returns false for paused status with past expiry', () => {
|
|
130
|
+
const past = new Date(Date.now() - 60000).toISOString();
|
|
131
|
+
expect(isAgentPaused(makeAgent({ status: 'paused', pausedUntil: past }))).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('returns true when pausedUntil is in future even without paused status', () => {
|
|
135
|
+
const future = new Date(Date.now() + 60000).toISOString();
|
|
136
|
+
expect(isAgentPaused(makeAgent({ pausedUntil: future }))).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('isExecutingTools', () => {
|
|
141
|
+
test('returns true when toolExecutionInProgress is true', () => {
|
|
142
|
+
expect(isExecutingTools({ toolExecutionInProgress: true })).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('returns false otherwise', () => {
|
|
146
|
+
expect(isExecutingTools({ toolExecutionInProgress: false })).toBe(false);
|
|
147
|
+
expect(isExecutingTools({})).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('shouldAgentBeActive', () => {
|
|
152
|
+
test('returns inactive for null agent', () => {
|
|
153
|
+
const result = shouldAgentBeActive(null);
|
|
154
|
+
expect(result.active).toBe(false);
|
|
155
|
+
expect(result.reason).toBe('agent-not-found');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('returns inactive for non-active status', () => {
|
|
159
|
+
const result = shouldAgentBeActive(makeAgent({ status: 'idle' }));
|
|
160
|
+
expect(result.active).toBe(false);
|
|
161
|
+
expect(result.reason).toBe('agent-inactive-status');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('returns inactive when delayed', () => {
|
|
165
|
+
const future = new Date(Date.now() + 60000).toISOString();
|
|
166
|
+
const result = shouldAgentBeActive(makeAgent({ delayEndTime: future }));
|
|
167
|
+
expect(result.active).toBe(false);
|
|
168
|
+
expect(result.reason).toBe('agent-delayed');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('returns inactive when paused', () => {
|
|
172
|
+
const result = shouldAgentBeActive(makeAgent({ status: 'active', pausedUntil: new Date(Date.now() + 60000).toISOString() }));
|
|
173
|
+
expect(result.active).toBe(false);
|
|
174
|
+
expect(result.reason).toBe('agent-paused');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('returns inactive when awaiting user input', () => {
|
|
178
|
+
const result = shouldAgentBeActive(makeAgent({ awaitingUserInput: { type: 'credentials' } }));
|
|
179
|
+
expect(result.active).toBe(false);
|
|
180
|
+
expect(result.reason).toBe('awaiting-user-input');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('returns inactive when stop requested', () => {
|
|
184
|
+
const result = shouldAgentBeActive(makeAgent({ stopRequested: true }));
|
|
185
|
+
expect(result.active).toBe(false);
|
|
186
|
+
expect(result.reason).toBe('stop-requested');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('returns active when TTL remaining', () => {
|
|
190
|
+
const result = shouldAgentBeActive(makeAgent({ ttl: 3 }));
|
|
191
|
+
expect(result.active).toBe(true);
|
|
192
|
+
expect(result.reason).toBe('has-ttl-remaining');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('AGENT mode: active when has pending tasks', () => {
|
|
196
|
+
const result = shouldAgentBeActive(makeAgent({
|
|
197
|
+
taskList: { tasks: [{ status: 'pending' }] }
|
|
198
|
+
}));
|
|
199
|
+
expect(result.active).toBe(true);
|
|
200
|
+
expect(result.reason).toBe('has-pending-tasks');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('AGENT mode: inactive when no pending tasks', () => {
|
|
204
|
+
const result = shouldAgentBeActive(makeAgent({
|
|
205
|
+
taskList: { tasks: [{ status: 'completed' }] }
|
|
206
|
+
}));
|
|
207
|
+
expect(result.active).toBe(false);
|
|
208
|
+
expect(result.reason).toBe('no-pending-work');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('CHAT mode: active when has user messages', () => {
|
|
212
|
+
const result = shouldAgentBeActive(makeAgent({
|
|
213
|
+
mode: 'chat',
|
|
214
|
+
messageQueues: { userMessages: [{ id: 1 }] }
|
|
215
|
+
}));
|
|
216
|
+
expect(result.active).toBe(true);
|
|
217
|
+
expect(result.reason).toBe('has-user-messages');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('CHAT mode: active when has inter-agent messages', () => {
|
|
221
|
+
const result = shouldAgentBeActive(makeAgent({
|
|
222
|
+
mode: 'chat',
|
|
223
|
+
messageQueues: { interAgentMessages: [{ id: 1 }] }
|
|
224
|
+
}));
|
|
225
|
+
expect(result.active).toBe(true);
|
|
226
|
+
expect(result.reason).toBe('has-inter-agent-messages');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('CHAT mode: inactive with only tool results', () => {
|
|
230
|
+
const result = shouldAgentBeActive(makeAgent({
|
|
231
|
+
mode: 'chat',
|
|
232
|
+
messageQueues: { toolResults: [{ id: 1 }] }
|
|
233
|
+
}));
|
|
234
|
+
expect(result.active).toBe(false);
|
|
235
|
+
expect(result.reason).toBe('chat-mode-no-messages');
|
|
236
|
+
expect(result.details).toContain('tool results');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('CHAT mode: inactive with no messages', () => {
|
|
240
|
+
const result = shouldAgentBeActive(makeAgent({ mode: 'chat' }));
|
|
241
|
+
expect(result.active).toBe(false);
|
|
242
|
+
expect(result.reason).toBe('chat-mode-no-messages');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('returns unknown mode for unrecognized mode', () => {
|
|
246
|
+
const result = shouldAgentBeActive(makeAgent({ mode: 'weird' }));
|
|
247
|
+
expect(result.active).toBe(false);
|
|
248
|
+
expect(result.reason).toBe('unknown-mode');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('getActiveAgents', () => {
|
|
253
|
+
test('filters active agents from an array', () => {
|
|
254
|
+
const agents = [
|
|
255
|
+
makeAgent({ id: 'a1', taskList: { tasks: [{ status: 'pending' }] } }),
|
|
256
|
+
makeAgent({ id: 'a2', status: 'idle' }),
|
|
257
|
+
makeAgent({ id: 'a3', taskList: { tasks: [{ status: 'pending' }] } })
|
|
258
|
+
];
|
|
259
|
+
const active = getActiveAgents(agents);
|
|
260
|
+
expect(active).toHaveLength(2);
|
|
261
|
+
expect(active.map(a => a.agentId)).toEqual(['a1', 'a3']);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('works with a Map input', () => {
|
|
265
|
+
const agents = new Map();
|
|
266
|
+
agents.set('a1', makeAgent({ id: 'a1', taskList: { tasks: [{ status: 'pending' }] } }));
|
|
267
|
+
agents.set('a2', makeAgent({ id: 'a2', status: 'idle' }));
|
|
268
|
+
const active = getActiveAgents(agents);
|
|
269
|
+
expect(active).toHaveLength(1);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('getAllAgentActivityStatus', () => {
|
|
274
|
+
test('returns detailed status for all agents', () => {
|
|
275
|
+
const agents = [
|
|
276
|
+
makeAgent({ id: 'a1', name: 'Agent 1', taskList: { tasks: [{ status: 'pending' }] } })
|
|
277
|
+
];
|
|
278
|
+
const statuses = getAllAgentActivityStatus(agents);
|
|
279
|
+
expect(statuses).toHaveLength(1);
|
|
280
|
+
expect(statuses[0].agentId).toBe('a1');
|
|
281
|
+
expect(statuses[0].active).toBe(true);
|
|
282
|
+
expect(statuses[0]).toHaveProperty('queueCounts');
|
|
283
|
+
expect(statuses[0]).toHaveProperty('hasPendingTasks');
|
|
284
|
+
expect(statuses[0]).toHaveProperty('isExecutingTools');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('works with Map input', () => {
|
|
288
|
+
const map = new Map();
|
|
289
|
+
map.set('a1', makeAgent({ id: 'a1' }));
|
|
290
|
+
const statuses = getAllAgentActivityStatus(map);
|
|
291
|
+
expect(statuses).toHaveLength(1);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('shouldSkipIteration', () => {
|
|
296
|
+
test('returns skip for null agent', () => {
|
|
297
|
+
const result = shouldSkipIteration(null);
|
|
298
|
+
expect(result.skip).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('returns skip when delayed', () => {
|
|
302
|
+
const future = new Date(Date.now() + 60000).toISOString();
|
|
303
|
+
const result = shouldSkipIteration(makeAgent({ delayEndTime: future }));
|
|
304
|
+
expect(result.skip).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('returns skip when paused with future expiry', () => {
|
|
308
|
+
const future = new Date(Date.now() + 60000).toISOString();
|
|
309
|
+
const result = shouldSkipIteration(makeAgent({ pausedUntil: future }));
|
|
310
|
+
expect(result.skip).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('returns no skip for normal agent', () => {
|
|
314
|
+
const result = shouldSkipIteration(makeAgent());
|
|
315
|
+
expect(result.skip).toBe(false);
|
|
316
|
+
expect(result.reason).toBeNull();
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
|
|
2
|
+
import { createMockLogger } from '../../__test-utils__/mockFactories.js';
|
|
3
|
+
|
|
4
|
+
// Mock fs/promises, crypto, os, and userDataDir before importing
|
|
5
|
+
const mockFs = {
|
|
6
|
+
readFile: jest.fn(),
|
|
7
|
+
writeFile: jest.fn()
|
|
8
|
+
};
|
|
9
|
+
const mockCrypto = {
|
|
10
|
+
randomBytes: jest.fn(() => Buffer.alloc(32, 'a')),
|
|
11
|
+
pbkdf2Sync: jest.fn(() => Buffer.alloc(32, 'k')),
|
|
12
|
+
createCipheriv: jest.fn(),
|
|
13
|
+
createDecipheriv: jest.fn()
|
|
14
|
+
};
|
|
15
|
+
const mockOs = {
|
|
16
|
+
hostname: jest.fn(() => 'test-host'),
|
|
17
|
+
homedir: jest.fn(() => '/home/test'),
|
|
18
|
+
userInfo: jest.fn(() => ({ username: 'testuser' }))
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
jest.unstable_mockModule('fs', () => ({
|
|
22
|
+
promises: mockFs
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
jest.unstable_mockModule('crypto', () => ({
|
|
26
|
+
default: mockCrypto,
|
|
27
|
+
...mockCrypto
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
jest.unstable_mockModule('os', () => ({
|
|
31
|
+
default: mockOs,
|
|
32
|
+
...mockOs
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
jest.unstable_mockModule('../../utilities/userDataDir.js', () => ({
|
|
36
|
+
getUserDataPaths: jest.fn(() => ({ settings: '/fake/settings', attachments: '/fake/attachments' })),
|
|
37
|
+
ensureUserDataDirs: jest.fn(async () => {})
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const { default: ApiKeyManager } = await import('../apiKeyManager.js');
|
|
41
|
+
|
|
42
|
+
describe('ApiKeyManager', () => {
|
|
43
|
+
let manager;
|
|
44
|
+
let logger;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
jest.clearAllMocks();
|
|
48
|
+
logger = createMockLogger();
|
|
49
|
+
manager = new ApiKeyManager(logger);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('constructor initializes with empty keys', () => {
|
|
53
|
+
expect(manager.keys.loxiaApiKey).toBeNull();
|
|
54
|
+
expect(manager.keys.vendorKeys).toEqual({});
|
|
55
|
+
expect(manager.initialized).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('initialize sets up persistence and loads keys', async () => {
|
|
59
|
+
// Mock salt file read failure (new salt generation)
|
|
60
|
+
mockFs.readFile.mockRejectedValueOnce(new Error('ENOENT'));
|
|
61
|
+
// Mock writeFile for salt
|
|
62
|
+
mockFs.writeFile.mockResolvedValue(undefined);
|
|
63
|
+
// Mock readFile for loadFromDisk - ENOENT (no existing keys)
|
|
64
|
+
const enoent = new Error('No file');
|
|
65
|
+
enoent.code = 'ENOENT';
|
|
66
|
+
mockFs.readFile.mockRejectedValueOnce(enoent);
|
|
67
|
+
|
|
68
|
+
await manager.initialize();
|
|
69
|
+
expect(manager.initialized).toBe(true);
|
|
70
|
+
expect(manager.persistenceFile).toContain('api-keys.enc');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('initialize only runs once', async () => {
|
|
74
|
+
manager.initialized = true;
|
|
75
|
+
await manager.initialize();
|
|
76
|
+
// Should not call ensureUserDataDirs if already initialized
|
|
77
|
+
expect(logger.info).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('initialize handles errors gracefully', async () => {
|
|
81
|
+
const { ensureUserDataDirs } = await import('../../utilities/userDataDir.js');
|
|
82
|
+
ensureUserDataDirs.mockRejectedValueOnce(new Error('disk full'));
|
|
83
|
+
|
|
84
|
+
await manager.initialize();
|
|
85
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('_getMachineIdentifier returns colon-separated string', () => {
|
|
89
|
+
const id = manager._getMachineIdentifier();
|
|
90
|
+
expect(id).toContain('test-host');
|
|
91
|
+
expect(id).toContain('testuser');
|
|
92
|
+
expect(id).toContain('loxia-api-key-encryption-v1');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('_encrypt throws when no encryption key', () => {
|
|
96
|
+
expect(() => manager._encrypt('test')).toThrow('Encryption key not initialized');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('_decrypt throws when no encryption key', () => {
|
|
100
|
+
expect(() => manager._decrypt('test')).toThrow('Encryption key not initialized');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('setSessionKeys stores loxia key and vendor keys', async () => {
|
|
104
|
+
manager.persistenceFile = null; // skip persist
|
|
105
|
+
await manager.setSessionKeys('sess-1', {
|
|
106
|
+
loxiaApiKey: 'loxia-key-123',
|
|
107
|
+
vendorKeys: { anthropic: 'anth-key' }
|
|
108
|
+
});
|
|
109
|
+
expect(manager.keys.loxiaApiKey).toBe('loxia-key-123');
|
|
110
|
+
expect(manager.keys.vendorKeys.anthropic).toBe('anth-key');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('setSessionKeys merges vendor keys', async () => {
|
|
114
|
+
manager.persistenceFile = null;
|
|
115
|
+
manager.keys.vendorKeys = { openai: 'oai-key' };
|
|
116
|
+
await manager.setSessionKeys('sess-1', {
|
|
117
|
+
vendorKeys: { anthropic: 'anth-key' }
|
|
118
|
+
});
|
|
119
|
+
expect(manager.keys.vendorKeys.openai).toBe('oai-key');
|
|
120
|
+
expect(manager.keys.vendorKeys.anthropic).toBe('anth-key');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('getSessionKeys returns keys regardless of sessionId', () => {
|
|
124
|
+
manager.keys.loxiaApiKey = 'test-key';
|
|
125
|
+
const keys = manager.getSessionKeys('any-session');
|
|
126
|
+
expect(keys.loxiaApiKey).toBe('test-key');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('getKeysForRequest returns loxia key for platform-provided', () => {
|
|
130
|
+
manager.keys.loxiaApiKey = 'loxia-123';
|
|
131
|
+
const result = manager.getKeysForRequest('sess', { platformProvided: true, vendor: 'anthropic' });
|
|
132
|
+
expect(result.loxiaApiKey).toBe('loxia-123');
|
|
133
|
+
expect(result.vendorApiKey).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('getKeysForRequest returns vendor key for direct access', () => {
|
|
137
|
+
manager.keys.loxiaApiKey = 'loxia-123';
|
|
138
|
+
manager.keys.vendorKeys = { anthropic: 'anth-key' };
|
|
139
|
+
const result = manager.getKeysForRequest('sess', { platformProvided: false, vendor: 'anthropic' });
|
|
140
|
+
expect(result.loxiaApiKey).toBe('loxia-123');
|
|
141
|
+
expect(result.vendorApiKey).toBe('anth-key');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('getKeysForRequest returns null vendor key when no vendor specified', () => {
|
|
145
|
+
const result = manager.getKeysForRequest('sess', {});
|
|
146
|
+
expect(result.vendorApiKey).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('removeSessionKeys clears all keys and returns true if had keys', async () => {
|
|
150
|
+
manager.persistenceFile = null;
|
|
151
|
+
manager.keys.loxiaApiKey = 'some-key';
|
|
152
|
+
const hadKeys = await manager.removeSessionKeys('sess');
|
|
153
|
+
expect(hadKeys).toBe(true);
|
|
154
|
+
expect(manager.keys.loxiaApiKey).toBeNull();
|
|
155
|
+
expect(manager.keys.vendorKeys).toEqual({});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('removeSessionKeys returns false if no keys existed', async () => {
|
|
159
|
+
const hadKeys = await manager.removeSessionKeys('sess');
|
|
160
|
+
expect(hadKeys).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('setGlobalKeys delegates to setSessionKeys', async () => {
|
|
164
|
+
manager.persistenceFile = null;
|
|
165
|
+
await manager.setGlobalKeys({ loxiaApiKey: 'global-key' });
|
|
166
|
+
expect(manager.keys.loxiaApiKey).toBe('global-key');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('getActiveSessions returns empty array', () => {
|
|
170
|
+
expect(manager.getActiveSessions()).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('cleanupExpiredSessions returns 0', () => {
|
|
174
|
+
expect(manager.cleanupExpiredSessions()).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('persist does nothing when not initialized', async () => {
|
|
178
|
+
manager.persistenceFile = null;
|
|
179
|
+
await manager.persist();
|
|
180
|
+
expect(mockFs.writeFile).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('loadFromDisk does nothing when not initialized', async () => {
|
|
184
|
+
manager.persistenceFile = null;
|
|
185
|
+
await manager.loadFromDisk();
|
|
186
|
+
// No errors expected
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('loadFromDisk handles ENOENT gracefully', async () => {
|
|
190
|
+
manager.persistenceFile = '/fake/file';
|
|
191
|
+
manager.encryptionKey = Buffer.alloc(32);
|
|
192
|
+
const err = new Error('not found');
|
|
193
|
+
err.code = 'ENOENT';
|
|
194
|
+
mockFs.readFile.mockRejectedValueOnce(err);
|
|
195
|
+
await manager.loadFromDisk();
|
|
196
|
+
expect(logger.debug).toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('loadFromDisk handles decrypt errors gracefully', async () => {
|
|
200
|
+
manager.persistenceFile = '/fake/file';
|
|
201
|
+
manager.encryptionKey = Buffer.alloc(32);
|
|
202
|
+
mockFs.readFile.mockRejectedValueOnce(new Error('bad data'));
|
|
203
|
+
await manager.loadFromDisk();
|
|
204
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
});
|