@xalia/agent 0.5.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -8
- package/dist/agent/src/agent/agent.js +173 -96
- package/dist/agent/src/agent/agentUtils.js +82 -53
- package/dist/agent/src/agent/compressingContextManager.js +102 -0
- package/dist/agent/src/agent/context.js +189 -0
- package/dist/agent/src/agent/dummyLLM.js +46 -5
- package/dist/agent/src/agent/iAgentEventHandler.js +2 -0
- package/dist/agent/src/agent/mcpServerManager.js +22 -23
- package/dist/agent/src/agent/nullAgentEventHandler.js +21 -0
- package/dist/agent/src/agent/nullPlatform.js +14 -0
- package/dist/agent/src/agent/openAILLMStreaming.js +12 -7
- package/dist/agent/src/agent/promptProvider.js +63 -0
- package/dist/agent/src/agent/repeatLLM.js +5 -5
- package/dist/agent/src/agent/sudoMcpServerManager.js +11 -9
- package/dist/agent/src/agent/tokenAuth.js +7 -7
- package/dist/agent/src/agent/tools.js +1 -1
- package/dist/agent/src/chat/client/chatClient.js +733 -0
- package/dist/agent/src/chat/client/connection.js +209 -0
- package/dist/agent/src/chat/client/connection.test.js +188 -0
- package/dist/agent/src/chat/client/constants.js +5 -0
- package/dist/agent/src/chat/client/index.js +15 -0
- package/dist/agent/src/chat/client/interfaces.js +2 -0
- package/dist/agent/src/chat/client/responseHandler.js +105 -0
- package/dist/agent/src/chat/client/sessionClient.js +331 -0
- package/dist/agent/src/chat/client/teamManager.js +2 -0
- package/dist/agent/src/chat/{apiKeyManager.js → data/apiKeyManager.js} +4 -0
- package/dist/agent/src/chat/data/dataModels.js +2 -0
- package/dist/agent/src/chat/data/database.js +749 -0
- package/dist/agent/src/chat/data/dbMcpServerConfigs.js +47 -0
- package/dist/agent/src/chat/protocol/connectionMessages.js +5 -0
- package/dist/agent/src/chat/protocol/constants.js +50 -0
- package/dist/agent/src/chat/protocol/errors.js +22 -0
- package/dist/agent/src/chat/protocol/messages.js +110 -0
- package/dist/agent/src/chat/server/chatContextManager.js +405 -0
- package/dist/agent/src/chat/server/connectionManager.js +352 -0
- package/dist/agent/src/chat/server/connectionManager.test.js +159 -0
- package/dist/agent/src/chat/server/conversation.js +198 -0
- package/dist/agent/src/chat/server/errorUtils.js +23 -0
- package/dist/agent/src/chat/server/openSession.js +869 -0
- package/dist/agent/src/chat/server/server.js +177 -0
- package/dist/agent/src/chat/server/sessionFileManager.js +161 -0
- package/dist/agent/src/chat/server/sessionRegistry.js +700 -0
- package/dist/agent/src/chat/server/sessionRegistry.test.js +97 -0
- package/dist/agent/src/chat/server/test-utils/mockFactories.js +307 -0
- package/dist/agent/src/chat/server/tools.js +243 -0
- package/dist/agent/src/chat/utils/agentSessionMap.js +66 -0
- package/dist/agent/src/chat/utils/approvalManager.js +85 -0
- package/dist/agent/src/{utils → chat/utils}/asyncLock.js +3 -3
- package/dist/agent/src/chat/{asyncQueue.js → utils/asyncQueue.js} +12 -2
- package/dist/agent/src/chat/utils/htmlToText.js +84 -0
- package/dist/agent/src/chat/utils/multiAsyncQueue.js +42 -0
- package/dist/agent/src/chat/utils/search.js +145 -0
- package/dist/agent/src/chat/utils/userResolver.js +46 -0
- package/dist/agent/src/chat/{websocket.js → utils/websocket.js} +2 -0
- package/dist/agent/src/test/agent.test.js +332 -0
- package/dist/agent/src/test/approvalManager.test.js +58 -0
- package/dist/agent/src/test/chatContextManager.test.js +392 -0
- package/dist/agent/src/test/clientServerConnection.test.js +158 -0
- package/dist/agent/src/test/compressingContextManager.test.js +65 -0
- package/dist/agent/src/test/context.test.js +83 -0
- package/dist/agent/src/test/conversation.test.js +89 -0
- package/dist/agent/src/test/db.test.js +262 -90
- package/dist/agent/src/test/dbMcpServerConfigs.test.js +72 -0
- package/dist/agent/src/test/dbTestTools.js +99 -0
- package/dist/agent/src/test/imageLoad.test.js +8 -7
- package/dist/agent/src/test/mcpServerManager.test.js +21 -18
- package/dist/agent/src/test/multiAsyncQueue.test.js +101 -0
- package/dist/agent/src/test/openaiStreaming.test.js +12 -11
- package/dist/agent/src/test/prompt.test.js +5 -4
- package/dist/agent/src/test/promptProvider.test.js +28 -0
- package/dist/agent/src/test/responseHandler.test.js +61 -0
- package/dist/agent/src/test/sudoMcpServerManager.test.js +14 -12
- package/dist/agent/src/test/testTools.js +109 -0
- package/dist/agent/src/test/tools.test.js +31 -0
- package/dist/agent/src/tool/agentChat.js +21 -10
- package/dist/agent/src/tool/agentMain.js +1 -1
- package/dist/agent/src/tool/chatMain.js +235 -58
- package/dist/agent/src/tool/commandPrompt.js +15 -9
- package/dist/agent/src/tool/files.js +20 -16
- package/dist/agent/src/tool/nodePlatform.js +47 -3
- package/dist/agent/src/tool/options.js +4 -4
- package/dist/agent/src/tool/prompt.js +19 -13
- package/eslint.config.mjs +14 -1
- package/package.json +14 -6
- package/scripts/chat_server +8 -0
- package/scripts/setup_chat +7 -2
- package/scripts/shutdown_chat_server +3 -0
- package/scripts/test_chat +135 -17
- package/src/agent/agent.ts +270 -135
- package/src/agent/agentUtils.ts +136 -95
- package/src/agent/compressingContextManager.ts +164 -0
- package/src/agent/context.ts +268 -0
- package/src/agent/dummyLLM.ts +76 -8
- package/src/agent/iAgentEventHandler.ts +54 -0
- package/src/agent/iplatform.ts +1 -0
- package/src/agent/mcpServerManager.ts +32 -30
- package/src/agent/nullAgentEventHandler.ts +20 -0
- package/src/agent/nullPlatform.ts +13 -0
- package/src/agent/openAILLMStreaming.ts +12 -6
- package/src/agent/promptProvider.ts +87 -0
- package/src/agent/repeatLLM.ts +5 -5
- package/src/agent/sudoMcpServerManager.ts +13 -11
- package/src/agent/tokenAuth.ts +7 -7
- package/src/agent/tools.ts +3 -1
- package/src/chat/client/chatClient.ts +900 -0
- package/src/chat/client/connection.test.ts +241 -0
- package/src/chat/client/connection.ts +276 -0
- package/src/chat/client/constants.ts +3 -0
- package/src/chat/client/index.ts +18 -0
- package/src/chat/client/interfaces.ts +34 -0
- package/src/chat/client/responseHandler.ts +131 -0
- package/src/chat/client/sessionClient.ts +443 -0
- package/src/chat/client/teamManager.ts +29 -0
- package/src/chat/{apiKeyManager.ts → data/apiKeyManager.ts} +6 -2
- package/src/chat/data/dataModels.ts +85 -0
- package/src/chat/data/database.ts +982 -0
- package/src/chat/data/dbMcpServerConfigs.ts +59 -0
- package/src/chat/protocol/connectionMessages.ts +49 -0
- package/src/chat/protocol/constants.ts +55 -0
- package/src/chat/protocol/errors.ts +16 -0
- package/src/chat/protocol/messages.ts +682 -0
- package/src/chat/server/README.md +127 -0
- package/src/chat/server/chatContextManager.ts +612 -0
- package/src/chat/server/connectionManager.test.ts +266 -0
- package/src/chat/server/connectionManager.ts +541 -0
- package/src/chat/server/conversation.ts +269 -0
- package/src/chat/server/errorUtils.ts +28 -0
- package/src/chat/server/openSession.ts +1332 -0
- package/src/chat/server/server.ts +177 -0
- package/src/chat/server/sessionFileManager.ts +239 -0
- package/src/chat/server/sessionRegistry.test.ts +138 -0
- package/src/chat/server/sessionRegistry.ts +1064 -0
- package/src/chat/server/test-utils/mockFactories.ts +422 -0
- package/src/chat/server/tools.ts +265 -0
- package/src/chat/utils/agentSessionMap.ts +76 -0
- package/src/chat/utils/approvalManager.ts +111 -0
- package/src/{utils → chat/utils}/asyncLock.ts +3 -3
- package/src/chat/{asyncQueue.ts → utils/asyncQueue.ts} +14 -3
- package/src/chat/utils/htmlToText.ts +61 -0
- package/src/chat/utils/multiAsyncQueue.ts +52 -0
- package/src/chat/utils/search.ts +139 -0
- package/src/chat/utils/userResolver.ts +48 -0
- package/src/chat/{websocket.ts → utils/websocket.ts} +2 -0
- package/src/test/agent.test.ts +487 -0
- package/src/test/approvalManager.test.ts +73 -0
- package/src/test/chatContextManager.test.ts +521 -0
- package/src/test/clientServerConnection.test.ts +207 -0
- package/src/test/compressingContextManager.test.ts +82 -0
- package/src/test/context.test.ts +105 -0
- package/src/test/conversation.test.ts +109 -0
- package/src/test/db.test.ts +351 -103
- package/src/test/dbMcpServerConfigs.test.ts +112 -0
- package/src/test/dbTestTools.ts +153 -0
- package/src/test/imageLoad.test.ts +7 -6
- package/src/test/mcpServerManager.test.ts +19 -14
- package/src/test/multiAsyncQueue.test.ts +125 -0
- package/src/test/openaiStreaming.test.ts +11 -10
- package/src/test/prompt.test.ts +4 -3
- package/src/test/promptProvider.test.ts +33 -0
- package/src/test/responseHandler.test.ts +78 -0
- package/src/test/sudoMcpServerManager.test.ts +22 -15
- package/src/test/testTools.ts +146 -0
- package/src/test/tools.test.ts +39 -0
- package/src/tool/agentChat.ts +26 -12
- package/src/tool/agentMain.ts +1 -1
- package/src/tool/chatMain.ts +283 -100
- package/src/tool/commandPrompt.ts +25 -9
- package/src/tool/files.ts +25 -19
- package/src/tool/nodePlatform.ts +52 -3
- package/src/tool/options.ts +4 -2
- package/src/tool/prompt.ts +22 -15
- package/test_data/dummyllm_script_crash.json +32 -0
- package/test_data/frog.png.b64 +1 -0
- package/vitest.config.ts +39 -0
- package/dist/agent/src/chat/client.js +0 -310
- package/dist/agent/src/chat/conversationManager.js +0 -502
- package/dist/agent/src/chat/db.js +0 -218
- package/dist/agent/src/chat/messages.js +0 -29
- package/dist/agent/src/chat/server.js +0 -158
- package/src/chat/client.ts +0 -445
- package/src/chat/conversationManager.ts +0 -730
- package/src/chat/db.ts +0 -304
- package/src/chat/messages.ts +0 -266
- package/src/chat/server.ts +0 -177
- /package/{frog.png → test_data/frog.png} +0 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ConnectionManager = void 0;
|
|
4
|
+
const uuid_1 = require("uuid");
|
|
5
|
+
const sdk_1 = require("@xalia/xmcp/sdk");
|
|
6
|
+
const errors_1 = require("../protocol/errors");
|
|
7
|
+
const logger = (0, sdk_1.getLogger)();
|
|
8
|
+
const HEARTBEAT_INTERVAL_MS = parseInt(process.env["HEARTBEAT_INTERVAL_MS"] || String(30 * 1000));
|
|
9
|
+
class Connection {
|
|
10
|
+
constructor(ws, userId, apiKey, heartbeat) {
|
|
11
|
+
this.ws = ws;
|
|
12
|
+
this.userId = userId;
|
|
13
|
+
this.apiKey = apiKey;
|
|
14
|
+
this.heartbeat = heartbeat;
|
|
15
|
+
this.lastMessageTime = Date.now();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Manages WebSocket connections and handles protocol-level message routing.
|
|
20
|
+
*
|
|
21
|
+
* - Connection handshake
|
|
22
|
+
* - Connection lifecycle management
|
|
23
|
+
* - Message routing between connections and sessions
|
|
24
|
+
* - Connection-to-user
|
|
25
|
+
*/
|
|
26
|
+
class ConnectionManager {
|
|
27
|
+
// this is a singleton object and can only be initialized by .init method
|
|
28
|
+
constructor(apiKeyManager, sessionMessageProcessor) {
|
|
29
|
+
this.apiKeyManager = apiKeyManager;
|
|
30
|
+
this.sessionMessageProcessor = sessionMessageProcessor;
|
|
31
|
+
// Connections by id
|
|
32
|
+
this.connections = new Map();
|
|
33
|
+
// userId -> connectionIds
|
|
34
|
+
this.userToConnections = new Map();
|
|
35
|
+
}
|
|
36
|
+
static init(apiKeyManager, createMsgProcessor) {
|
|
37
|
+
const connManager = new ConnectionManager(apiKeyManager, {});
|
|
38
|
+
connManager.sessionMessageProcessor = createMsgProcessor(connManager);
|
|
39
|
+
return connManager;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Handle new WebSocket connection with handshake protocol
|
|
43
|
+
* TODO: proper error code returning
|
|
44
|
+
*/
|
|
45
|
+
async handleConnection(webSocket, apiKey, req) {
|
|
46
|
+
const connectionId = (0, uuid_1.v4)();
|
|
47
|
+
logger.info(`[ConnectionManager] New connection: ${connectionId}`);
|
|
48
|
+
try {
|
|
49
|
+
// Verify API key
|
|
50
|
+
const userData = await this.apiKeyManager.verifyApiKey(apiKey);
|
|
51
|
+
if (!userData) {
|
|
52
|
+
logger.error(`[ConnectionManager] Invalid API key: ${apiKey}`);
|
|
53
|
+
throw new errors_1.ChatFatalError("invalid api key");
|
|
54
|
+
}
|
|
55
|
+
// Heartbeat interval
|
|
56
|
+
const heartbeat = setInterval(this.onHeartbeat.bind(this, connectionId), HEARTBEAT_INTERVAL_MS);
|
|
57
|
+
// Register connection
|
|
58
|
+
this.connections.set(connectionId, new Connection(webSocket, userData.uuid, apiKey, heartbeat));
|
|
59
|
+
// Add to user connections
|
|
60
|
+
if (!this.userToConnections.has(userData.uuid)) {
|
|
61
|
+
this.userToConnections.set(userData.uuid, new Set());
|
|
62
|
+
}
|
|
63
|
+
const userConnections = this.userToConnections.get(userData.uuid);
|
|
64
|
+
if (userConnections) {
|
|
65
|
+
userConnections.add(connectionId);
|
|
66
|
+
}
|
|
67
|
+
// Setup connection handlers
|
|
68
|
+
this.setupConnectionHandlers(connectionId, webSocket);
|
|
69
|
+
// Send connection_ready after WebSocket is fully open
|
|
70
|
+
const clientVersion = req?.headers["x-client-version"] || "unknown";
|
|
71
|
+
const response = {
|
|
72
|
+
t: "ready",
|
|
73
|
+
c_id: connectionId,
|
|
74
|
+
user_uuid: userData.uuid,
|
|
75
|
+
};
|
|
76
|
+
// Check if connection still exists before sending
|
|
77
|
+
if (this.connections.has(connectionId)) {
|
|
78
|
+
this.sendConnectionReadyMessage(connectionId, response);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
logger.warn(`[ConnectionManager] Connection ${connectionId} was closed` +
|
|
82
|
+
` before ready message could be sent`);
|
|
83
|
+
}
|
|
84
|
+
logger.info(`[ConnectionManager] Connection ${connectionId} registered ` +
|
|
85
|
+
`for user ${userData.uuid}, version: ${clientVersion}`);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
89
|
+
logger.error(`[ConnectionManager] Failed to setup connection ${connectionId}:`, errorMessage);
|
|
90
|
+
// Send error to client if possible
|
|
91
|
+
this.sendErrorDirect(webSocket, connectionId, errorMessage);
|
|
92
|
+
// Force cleanup of any partial state
|
|
93
|
+
this.forceCleanupConnection(connectionId);
|
|
94
|
+
// Close WebSocket - this might trigger handlers if they were attached,
|
|
95
|
+
// but we've already cleaned up state above as a safeguard
|
|
96
|
+
try {
|
|
97
|
+
webSocket.close(4000, "Connection setup failed");
|
|
98
|
+
}
|
|
99
|
+
catch (closeError) {
|
|
100
|
+
logger.error(`[ConnectionManager] Error closing WebSocket ${connectionId}:`, closeError);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Send connection_ready message with unified error handling
|
|
106
|
+
*/
|
|
107
|
+
sendConnectionReadyMessage(connectionId, response) {
|
|
108
|
+
try {
|
|
109
|
+
this.sendConnectionMessage(connectionId, response);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
logger.error(`[ConnectionManager] Failed connection_ready to ${connectionId}:`, error);
|
|
113
|
+
this.forceCleanupConnection(connectionId);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Send error message to connection directly.
|
|
118
|
+
* This is used when the connection is possibly not yet registered.
|
|
119
|
+
*/
|
|
120
|
+
sendErrorDirect(ws, connectionId, errorMessage) {
|
|
121
|
+
const errorMsg = {
|
|
122
|
+
t: "error",
|
|
123
|
+
e: errorMessage,
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
if (ws.readyState === ws.OPEN) {
|
|
127
|
+
ws.send(JSON.stringify(errorMsg));
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
logger.error(`[ConnectionManager] Conn not open sending error ${connectionId}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
logger.error(`[ConnectionManager] Failed to send error to connection` +
|
|
135
|
+
` ${connectionId}:`, error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Setup message and lifecycle handlers for a connection
|
|
140
|
+
*/
|
|
141
|
+
setupConnectionHandlers(connectionId, webSocket) {
|
|
142
|
+
// TODO: keep the Connection object in the closure here instead of always
|
|
143
|
+
// doing many (expensive) lookups.
|
|
144
|
+
// set message handler for incoming messages
|
|
145
|
+
webSocket.on("message", (data) => {
|
|
146
|
+
void (async () => {
|
|
147
|
+
try {
|
|
148
|
+
const msgStr = data.toString("utf8");
|
|
149
|
+
logger.info(`[ConnectionManager] Message from ${connectionId}: ${msgStr}`);
|
|
150
|
+
const message = JSON.parse(msgStr);
|
|
151
|
+
await this.routeMessage(connectionId, message);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
logger.error(`[ConnectionManager] Error processing message:`, error);
|
|
155
|
+
this.handleError(connectionId, error);
|
|
156
|
+
}
|
|
157
|
+
})();
|
|
158
|
+
});
|
|
159
|
+
webSocket.on("close", () => {
|
|
160
|
+
logger.debug(`[ConnectionManager] Connection ${connectionId} closed`);
|
|
161
|
+
this.handleConnectionClose(connectionId);
|
|
162
|
+
});
|
|
163
|
+
webSocket.on("error", (error) => {
|
|
164
|
+
logger.error(`[ConnectionManager] WebSocket error on ${connectionId}:`, error);
|
|
165
|
+
this.handleConnectionClose(connectionId);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Route incoming message to appropriate handler based on message type
|
|
170
|
+
*/
|
|
171
|
+
async routeMessage(connectionId, msg) {
|
|
172
|
+
const conn = this.connections.get(connectionId);
|
|
173
|
+
if (!conn) {
|
|
174
|
+
throw new errors_1.ChatErrorMessage(`Connection ${connectionId} not found for message routing`);
|
|
175
|
+
}
|
|
176
|
+
conn.lastMessageTime = Date.now();
|
|
177
|
+
// Handle Connection-level messages
|
|
178
|
+
{
|
|
179
|
+
switch (msg.t) {
|
|
180
|
+
case "data":
|
|
181
|
+
await this.sessionMessageProcessor.processMessage(connectionId, conn.userId, msg.d);
|
|
182
|
+
break;
|
|
183
|
+
case "pong":
|
|
184
|
+
// The only operation is to update the `lastMessageTime`, done above
|
|
185
|
+
logger.debug(`[ConnectionManager.routeMessage] pong from ${connectionId}`);
|
|
186
|
+
break;
|
|
187
|
+
default: {
|
|
188
|
+
const _ = msg;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
onHeartbeat(connectionId) {
|
|
195
|
+
// TODO: move this and other handling to the Connection class so we can
|
|
196
|
+
// avoid all the per-event lookups.
|
|
197
|
+
const conn = this.connections.get(connectionId);
|
|
198
|
+
if (!conn) {
|
|
199
|
+
// TODO: We intentionally don't try to clean up here, to force
|
|
200
|
+
// correctness fo the real cleanup code.
|
|
201
|
+
logger.error(`[onHeartbeat] unexpected callback for connId ${connectionId}`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const timeSinceLast = now - conn.lastMessageTime;
|
|
206
|
+
logger.debug(`[onHeartbeat] conn ${connectionId} since ${String(timeSinceLast)}`);
|
|
207
|
+
if (timeSinceLast > 2 * HEARTBEAT_INTERVAL_MS) {
|
|
208
|
+
this.forceCleanupConnection(connectionId);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const msg = { t: "ping" };
|
|
212
|
+
this.sendConnectionMessage(connectionId, msg);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Force cleanup of connection state - used for error recovery.
|
|
216
|
+
* This method is safe to call multiple times and handles partial state.
|
|
217
|
+
*/
|
|
218
|
+
forceCleanupConnection(connectionId) {
|
|
219
|
+
const conn = this.connections.get(connectionId);
|
|
220
|
+
if (conn) {
|
|
221
|
+
// Cancel the heartbeat interval
|
|
222
|
+
clearInterval(conn.heartbeat);
|
|
223
|
+
// Remove from user connections
|
|
224
|
+
const userConnections = this.userToConnections.get(conn.userId);
|
|
225
|
+
if (userConnections) {
|
|
226
|
+
userConnections.delete(connectionId);
|
|
227
|
+
if (userConnections.size === 0) {
|
|
228
|
+
this.userToConnections.delete(conn.userId);
|
|
229
|
+
this.sessionMessageProcessor.handleUserDisconnect(conn.userId);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
this.connections.delete(connectionId);
|
|
233
|
+
}
|
|
234
|
+
logger.debug(`[ConnectionManager] Force cleaned up connection ${connectionId}`);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Handle connection close - cleanup all state.
|
|
238
|
+
* This is the normal cleanup path triggered by WebSocket events.
|
|
239
|
+
*/
|
|
240
|
+
handleConnectionClose(connectionId) {
|
|
241
|
+
this.forceCleanupConnection(connectionId);
|
|
242
|
+
logger.info(`[ConnectionManager] Connection ${connectionId} closed`);
|
|
243
|
+
}
|
|
244
|
+
sendConnectionMessage(connectionId, message) {
|
|
245
|
+
const conn = this.connections.get(connectionId);
|
|
246
|
+
if (!conn) {
|
|
247
|
+
logger.warn(`[ConnectionManager] Cannot send to connection ${connectionId} - ` +
|
|
248
|
+
`not found`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const webSocket = conn.ws;
|
|
252
|
+
if (webSocket.readyState !== 1) {
|
|
253
|
+
// WebSocket.OPEN = 1
|
|
254
|
+
logger.warn(`[ConnectionManager] Cannot send to connection ${connectionId} - ` +
|
|
255
|
+
`not open`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const msgString = JSON.stringify(message);
|
|
259
|
+
logger.debug(`[ConnectionManager] Sending to ${connectionId}: ${msgString}`);
|
|
260
|
+
webSocket.send(msgString);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Send message to specific connection.
|
|
264
|
+
*/
|
|
265
|
+
sendToConnection(connectionId, message) {
|
|
266
|
+
this.sendConnectionMessage(connectionId, { t: "data", d: message });
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Send error message to connection
|
|
270
|
+
*/
|
|
271
|
+
sendConnectionError(connectionId, errorMessage) {
|
|
272
|
+
const errorMsg = {
|
|
273
|
+
t: "error",
|
|
274
|
+
e: errorMessage,
|
|
275
|
+
};
|
|
276
|
+
this.sendConnectionMessage(connectionId, errorMsg);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Handle errors during message processing
|
|
280
|
+
*/
|
|
281
|
+
handleError(connectionId, error) {
|
|
282
|
+
let message;
|
|
283
|
+
let shouldClose = false;
|
|
284
|
+
if (typeof error === "string") {
|
|
285
|
+
message = error;
|
|
286
|
+
}
|
|
287
|
+
else if (error instanceof errors_1.ChatFatalError) {
|
|
288
|
+
message = error.message;
|
|
289
|
+
shouldClose = true;
|
|
290
|
+
}
|
|
291
|
+
else if (error instanceof errors_1.ChatErrorMessage) {
|
|
292
|
+
message = error.message;
|
|
293
|
+
}
|
|
294
|
+
else if (error instanceof Error) {
|
|
295
|
+
message = "Internal server error: " + error.message;
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
message = "Unknown error occurred";
|
|
299
|
+
}
|
|
300
|
+
logger.warn(`[ConnectionManager] Error on connection ${connectionId}: ${message}`);
|
|
301
|
+
this.sendConnectionError(connectionId, message);
|
|
302
|
+
if (shouldClose) {
|
|
303
|
+
const conn = this.connections.get(connectionId);
|
|
304
|
+
logger.info(`[ConnectionManager] Closing connection ${connectionId} ` +
|
|
305
|
+
`due to error: ${message}`);
|
|
306
|
+
if (conn) {
|
|
307
|
+
conn.ws.close(4000, message);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Implementation of IUserConnectionManager interface.
|
|
313
|
+
* Send message to all active connections of specific users.
|
|
314
|
+
*/
|
|
315
|
+
sendToUsers(userIds, message) {
|
|
316
|
+
for (const userId of userIds.values()) {
|
|
317
|
+
const connectionIds = this.getUserConnections(userId);
|
|
318
|
+
for (const connectionId of connectionIds) {
|
|
319
|
+
logger.debug(`[ConnectionManager] sending to user ${userId} ` +
|
|
320
|
+
`connection ${connectionId}`);
|
|
321
|
+
this.sendToConnection(connectionId, message);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Get all active connection IDs for a user.
|
|
327
|
+
* Helper method for sendToUsers implementation.
|
|
328
|
+
*/
|
|
329
|
+
getUserConnections(userId) {
|
|
330
|
+
const connections = this.userToConnections.get(userId);
|
|
331
|
+
return connections ? Array.from(connections) : [];
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Implementation of IUserConnectionManager interface.
|
|
335
|
+
* Get cached api key for a user. This key will not be available
|
|
336
|
+
* after the user disconnects.
|
|
337
|
+
*/
|
|
338
|
+
getLiveUserApiKey(userId) {
|
|
339
|
+
const connectionIds = this.getUserConnections(userId);
|
|
340
|
+
// Return API key from the first active connection
|
|
341
|
+
// (all connections for a user should have the same API key)
|
|
342
|
+
for (const connectionId of connectionIds) {
|
|
343
|
+
const conn = this.connections.get(connectionId);
|
|
344
|
+
if (conn) {
|
|
345
|
+
return conn.apiKey;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// No active connections for this user
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
exports.ConnectionManager = ConnectionManager;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
4
|
+
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
5
|
+
const vitest_1 = require("vitest");
|
|
6
|
+
const connectionManager_1 = require("./connectionManager");
|
|
7
|
+
const mockFactories_1 = require("./test-utils/mockFactories");
|
|
8
|
+
// Mock dependencies
|
|
9
|
+
vitest_1.vi.mock("../data/apiKeyManager");
|
|
10
|
+
vitest_1.vi.mock("ws");
|
|
11
|
+
(0, vitest_1.describe)("ConnectionManager", () => {
|
|
12
|
+
let connectionManager;
|
|
13
|
+
let mockApiKeyManager;
|
|
14
|
+
let mockSessionRegistry;
|
|
15
|
+
let mockWebSocket;
|
|
16
|
+
(0, vitest_1.beforeEach)(() => {
|
|
17
|
+
vitest_1.vi.resetAllMocks();
|
|
18
|
+
// Create mocks using factories
|
|
19
|
+
mockApiKeyManager = (0, mockFactories_1.createMockApiKeyManager)();
|
|
20
|
+
mockSessionRegistry = (0, mockFactories_1.createMockSessionRegistry)();
|
|
21
|
+
mockWebSocket = (0, mockFactories_1.createMockWebSocket)();
|
|
22
|
+
// Setup standard behaviors
|
|
23
|
+
(0, mockFactories_1.setupStandardMockBehaviors)({
|
|
24
|
+
apiKeyManager: mockApiKeyManager,
|
|
25
|
+
});
|
|
26
|
+
// Create ConnectionManager instance using reflection
|
|
27
|
+
// to access private constructor. Since the constructor is private,
|
|
28
|
+
// we need to use type assertion
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
connectionManager = new connectionManager_1.ConnectionManager(mockApiKeyManager.mock, mockSessionRegistry.mock);
|
|
31
|
+
});
|
|
32
|
+
(0, vitest_1.afterEach)(() => {
|
|
33
|
+
vitest_1.vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
(0, vitest_1.describe)("handleConnection", () => {
|
|
36
|
+
(0, vitest_1.it)("should send connection_ready after successful setup", async () => {
|
|
37
|
+
await connectionManager.handleConnection(mockWebSocket.mock, "valid-api-key", { headers: {} });
|
|
38
|
+
// Wait for deferred send
|
|
39
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
40
|
+
(0, vitest_1.expect)(mockApiKeyManager.spies.verifyApiKey).toHaveBeenCalledWith("valid-api-key");
|
|
41
|
+
(0, vitest_1.expect)(mockWebSocket.spies.send).toHaveBeenCalledWith(vitest_1.expect.stringMatching(/"t":"ready"/));
|
|
42
|
+
});
|
|
43
|
+
(0, vitest_1.it)("should reject connection with invalid API key", async () => {
|
|
44
|
+
await connectionManager.handleConnection(mockWebSocket.mock, "invalid-api-key");
|
|
45
|
+
(0, vitest_1.expect)(mockWebSocket.spies.close).toHaveBeenCalledWith(4000, "Connection setup failed");
|
|
46
|
+
});
|
|
47
|
+
(0, vitest_1.it)("should setup WebSocket event handlers", async () => {
|
|
48
|
+
await connectionManager.handleConnection(mockWebSocket.mock, "valid-api-key");
|
|
49
|
+
(0, vitest_1.expect)(mockWebSocket.spies.on).toHaveBeenCalledWith("message", vitest_1.expect.any(Function));
|
|
50
|
+
(0, vitest_1.expect)(mockWebSocket.spies.on).toHaveBeenCalledWith("close", vitest_1.expect.any(Function));
|
|
51
|
+
(0, vitest_1.expect)(mockWebSocket.spies.on).toHaveBeenCalledWith("error", vitest_1.expect.any(Function));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
(0, vitest_1.describe)("user communication", () => {
|
|
55
|
+
(0, vitest_1.beforeEach)(async () => {
|
|
56
|
+
await connectionManager.handleConnection(mockWebSocket.mock, "valid-api-key");
|
|
57
|
+
});
|
|
58
|
+
(0, vitest_1.it)("should send messages to connected users", () => {
|
|
59
|
+
const testMessage = {
|
|
60
|
+
t: "data",
|
|
61
|
+
d: {
|
|
62
|
+
type: "user_msg",
|
|
63
|
+
user_uuid: "user_1",
|
|
64
|
+
session_id: "session_1",
|
|
65
|
+
message: "message text",
|
|
66
|
+
message_idx: 12,
|
|
67
|
+
// connection_id: "test-123",
|
|
68
|
+
// user_uuid: MOCK_USERS.owner.uuid,
|
|
69
|
+
// client_message_id: "test-123",
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
connectionManager.sendToUsers(new Set([mockFactories_1.MOCK_USERS.owner.uuid]), testMessage.d);
|
|
73
|
+
(0, vitest_1.expect)(mockWebSocket.spies.send).toHaveBeenCalledWith(JSON.stringify(testMessage));
|
|
74
|
+
});
|
|
75
|
+
(0, vitest_1.it)("should return API key for connected user", () => {
|
|
76
|
+
const apiKey = connectionManager.getLiveUserApiKey(mockFactories_1.MOCK_USERS.owner.uuid);
|
|
77
|
+
(0, vitest_1.expect)(apiKey).toBe("valid-api-key");
|
|
78
|
+
});
|
|
79
|
+
(0, vitest_1.it)("should return undefined for non-existent user", () => {
|
|
80
|
+
const apiKey = connectionManager.getLiveUserApiKey(mockFactories_1.MOCK_USERS.nonExistent.uuid);
|
|
81
|
+
(0, vitest_1.expect)(apiKey).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
(0, vitest_1.describe)("connection cleanup", () => {
|
|
85
|
+
(0, vitest_1.beforeEach)(async () => {
|
|
86
|
+
await connectionManager.handleConnection(mockWebSocket.mock, "valid-api-key");
|
|
87
|
+
});
|
|
88
|
+
(0, vitest_1.it)("should clean up connection state on close", () => {
|
|
89
|
+
// Get the close handler from mock calls
|
|
90
|
+
const closeHandlerCall = mockWebSocket.spies.on.mock.calls.find((call) => call[0] === "close");
|
|
91
|
+
(0, vitest_1.expect)(closeHandlerCall).toBeDefined();
|
|
92
|
+
if (closeHandlerCall) {
|
|
93
|
+
const closeHandler = closeHandlerCall[1];
|
|
94
|
+
closeHandler();
|
|
95
|
+
}
|
|
96
|
+
// After cleanup, API key should no longer be available
|
|
97
|
+
const apiKey = connectionManager.getLiveUserApiKey(mockFactories_1.MOCK_USERS.owner.uuid);
|
|
98
|
+
(0, vitest_1.expect)(apiKey).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
(0, vitest_1.it)("should clean up connection state on error", () => {
|
|
101
|
+
// Get the error handler from mock calls
|
|
102
|
+
const errorHandlerCall = mockWebSocket.spies.on.mock.calls.find((call) => call[0] === "error");
|
|
103
|
+
(0, vitest_1.expect)(errorHandlerCall).toBeDefined();
|
|
104
|
+
if (errorHandlerCall) {
|
|
105
|
+
const errorHandler = errorHandlerCall[1];
|
|
106
|
+
errorHandler(new Error("WebSocket error"));
|
|
107
|
+
}
|
|
108
|
+
// After cleanup, API key should no longer be available
|
|
109
|
+
const apiKey = connectionManager.getLiveUserApiKey(mockFactories_1.MOCK_USERS.owner.uuid);
|
|
110
|
+
(0, vitest_1.expect)(apiKey).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
(0, vitest_1.describe)("message routing", () => {
|
|
114
|
+
let messageHandler;
|
|
115
|
+
(0, vitest_1.beforeEach)(async () => {
|
|
116
|
+
await connectionManager.handleConnection(mockWebSocket.mock, "valid-api-key");
|
|
117
|
+
// Extract the message handler
|
|
118
|
+
const messageHandlerCall = mockWebSocket.spies.on.mock.calls.find((call) => call[0] === "message");
|
|
119
|
+
(0, vitest_1.expect)(messageHandlerCall).toBeDefined();
|
|
120
|
+
if (!messageHandlerCall) {
|
|
121
|
+
throw new Error("Message handler not found");
|
|
122
|
+
}
|
|
123
|
+
messageHandler = messageHandlerCall[1];
|
|
124
|
+
});
|
|
125
|
+
(0, vitest_1.it)("should route session_create to SessionRegistry", async () => {
|
|
126
|
+
mockSessionRegistry.spies.processMessage.mockResolvedValue(undefined);
|
|
127
|
+
const message = {
|
|
128
|
+
t: "data",
|
|
129
|
+
d: {
|
|
130
|
+
type: "control_session_create",
|
|
131
|
+
title: "Test Session",
|
|
132
|
+
agent_profile_id: "agent-123",
|
|
133
|
+
client_message_id: "msg-123",
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
await messageHandler(Buffer.from(JSON.stringify(message)));
|
|
137
|
+
(0, vitest_1.expect)(mockSessionRegistry.spies.processMessage).toHaveBeenCalledWith(vitest_1.expect.any(String), // connectionId
|
|
138
|
+
mockFactories_1.MOCK_USERS.owner.uuid, message.d);
|
|
139
|
+
});
|
|
140
|
+
(0, vitest_1.it)("should route session messages to SessionRegistry", async () => {
|
|
141
|
+
const message = {
|
|
142
|
+
t: "data",
|
|
143
|
+
d: {
|
|
144
|
+
type: "msg",
|
|
145
|
+
session_id: "session-123",
|
|
146
|
+
message: "Hello world!",
|
|
147
|
+
client_message_id: "msg-123",
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
await messageHandler(Buffer.from(JSON.stringify(message)));
|
|
151
|
+
(0, vitest_1.expect)(mockSessionRegistry.spies.processMessage).toHaveBeenCalledWith(vitest_1.expect.any(String), // connectionId
|
|
152
|
+
mockFactories_1.MOCK_USERS.owner.uuid, message.d);
|
|
153
|
+
});
|
|
154
|
+
(0, vitest_1.it)("should handle invalid JSON gracefully", async () => {
|
|
155
|
+
await messageHandler(Buffer.from("invalid json"));
|
|
156
|
+
(0, vitest_1.expect)(mockWebSocket.spies.send).toHaveBeenCalledWith(vitest_1.expect.stringMatching(/"t":"error"/));
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|