@testdriverai/mcp 7.8.0-canary.12 → 7.8.0-canary.13

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.
@@ -29,6 +29,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
29
29
  this._lastConnectParams = null;
30
30
  this._teamId = null;
31
31
  this._sandboxId = null;
32
+ this._disconnectedAt = null; // tracks when Ably connection dropped (for timeout extension on reconnect)
32
33
 
33
34
  // Rate limiting state for Ably publishes (Ably limits to 50 msg/sec per connection)
34
35
  this._publishLastTime = 0;
@@ -98,7 +99,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
98
99
  }
99
100
 
100
101
  // Save subscription references for historyBeforeSubscribe() during discontinuity recovery
101
- this._responseSubscription = await this._sessionChannel.subscribe("response", function (msg) {
102
+ this._onResponseMsg = function (msg) {
102
103
  var message = msg.data;
103
104
  if (!message) return;
104
105
 
@@ -154,31 +155,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
154
155
  }
155
156
 
156
157
  if (!message.requestId || !self.ps[message.requestId]) {
157
- var debugMode =
158
- process.env.VERBOSE || process.env.TD_DEBUG;
159
- if (debugMode) {
160
- console.warn(
161
- "No pending promise found for requestId:",
162
- message.requestId,
163
- );
164
- }
158
+ var pendingIds = Object.keys(self.ps);
159
+ var pendingSummary = pendingIds.length > 0
160
+ ? pendingIds.map(function (rid) {
161
+ var e = self.ps[rid];
162
+ return rid + '(' + (e && e.message ? e.message.type : '?') + ')';
163
+ }).join(', ')
164
+ : 'none';
165
+ logger.warn(
166
+ '[ably] No pending promise for requestId=' + (message.requestId || 'null') +
167
+ ' | response type=' + (message.type || 'unknown') +
168
+ ' | error=' + (message.error ? (message.errorMessage || 'true') : 'false') +
169
+ ' | currently pending: [' + pendingSummary + ']'
170
+ );
165
171
  return;
166
172
  }
167
173
 
168
174
  if (message.error) {
169
- var pendingMessage =
170
- self.ps[message.requestId] &&
171
- self.ps[message.requestId].message;
175
+ var pendingEntry = self.ps[message.requestId];
176
+ var pendingMessage = pendingEntry && pendingEntry.message;
177
+ var pendingAge = pendingEntry && pendingEntry.startTime
178
+ ? ((Date.now() - pendingEntry.startTime) / 1000).toFixed(1) + 's'
179
+ : '?';
180
+ logger.warn(
181
+ '[ably] Promise REJECTED: requestId=' + message.requestId +
182
+ ' | type=' + (pendingMessage ? pendingMessage.type : 'unknown') +
183
+ ' | age=' + pendingAge +
184
+ ' | error=' + (message.errorMessage || 'Sandbox error')
185
+ );
172
186
  if (!pendingMessage || pendingMessage.type !== "output") {
173
187
  emitter.emit(events.error.sandbox, message.errorMessage);
174
188
  }
175
189
  var error = new Error(message.errorMessage || "Sandbox error");
176
190
  error.responseData = message;
177
191
  delete self._execBuffers[message.requestId];
178
- self.ps[message.requestId].reject(error);
192
+ pendingEntry.reject(error);
179
193
  } else {
180
194
  emitter.emit(events.sandbox.received);
181
195
  if (self.ps[message.requestId]) {
196
+ var resolveEntry = self.ps[message.requestId];
197
+ var resolveAge = resolveEntry.startTime
198
+ ? ((Date.now() - resolveEntry.startTime) / 1000).toFixed(1) + 's'
199
+ : '?';
200
+ logger.log(
201
+ '[ably] Promise RESOLVED: requestId=' + message.requestId +
202
+ ' | type=' + (resolveEntry.message ? resolveEntry.message.type : 'unknown') +
203
+ ' | age=' + resolveAge
204
+ );
182
205
  // Unwrap the result from the Ably response envelope
183
206
  // The runner sends { requestId, type, result, success }
184
207
  // But SDK commands expect just the result object
@@ -197,9 +220,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
197
220
  }
198
221
  }
199
222
  delete self.ps[message.requestId];
200
- });
223
+ };
224
+ this._responseSubscription = await this._sessionChannel.subscribe("response", this._onResponseMsg);
201
225
 
202
- this._fileSubscription = await this._sessionChannel.subscribe("file", function (msg) {
226
+ this._onFileMsg = function (msg) {
203
227
  var message = msg.data;
204
228
  if (!message) return;
205
229
  logger.log(`[ably] Received file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
@@ -209,7 +233,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
209
233
  delete self.ps[message.requestId];
210
234
  }
211
235
  emitter.emit(events.sandbox.file, message);
212
- });
236
+ };
237
+ this._fileSubscription = await this._sessionChannel.subscribe("file", this._onFileMsg);
213
238
 
214
239
  this.heartbeat = setInterval(function () { }, 5000);
215
240
  if (this.heartbeat.unref) this.heartbeat.unref();
@@ -218,18 +243,50 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
218
243
  this._statsInterval = setInterval(() => {
219
244
  const connState = this._ably ? this._ably.connection.state : 'no-client';
220
245
  const chState = this._sessionChannel ? this._sessionChannel.state : 'null';
221
- const pending = Object.keys(this.ps).length;
246
+ const pendingIds = Object.keys(this.ps);
247
+ const pending = pendingIds.length;
222
248
  logger.log(`[ably][stats] connection=${connState} | sandbox=${this._sandboxId} | pending=${pending} | channel=${chState}`);
249
+ if (pending > 0) {
250
+ const now = Date.now();
251
+ for (const rid of pendingIds) {
252
+ const entry = this.ps[rid];
253
+ if (!entry) continue;
254
+ const type = entry.message ? entry.message.type : 'unknown';
255
+ const ageSec = ((now - (entry.startTime || now)) / 1000).toFixed(1);
256
+ logger.log(`[ably][stats] pending: requestId=${rid} | type=${type} | age=${ageSec}s`);
257
+ }
258
+ }
223
259
  }, 10000);
224
260
  if (this._statsInterval.unref) this._statsInterval.unref();
225
261
 
226
262
  this._ably.connection.on("disconnected", function () {
227
263
  logger.log("[ably] Connection: disconnected - will auto-reconnect");
264
+ self._disconnectedAt = Date.now();
228
265
  });
229
266
 
230
267
  this._ably.connection.on("connected", function () {
231
268
  // Log reconnection so the user knows the blip was recovered
232
269
  logger.log("[ably] Connection: reconnected");
270
+ // Extend any pending command timeouts by the disconnection duration so
271
+ // commands whose timer was counting down while the connection was down
272
+ // don't get incorrectly timed out.
273
+ if (self._disconnectedAt) {
274
+ var disconnectionDurationMs = Date.now() - self._disconnectedAt;
275
+ self._disconnectedAt = null;
276
+ var pendingIds = Object.keys(self.ps);
277
+ if (pendingIds.length > 0) {
278
+ logger.log(
279
+ '[ably] Extending ' + pendingIds.length + ' pending timeout(s) by ' +
280
+ disconnectionDurationMs + 'ms after disconnection'
281
+ );
282
+ for (var i = 0; i < pendingIds.length; i++) {
283
+ var entry = self.ps[pendingIds[i]];
284
+ if (entry && typeof entry.extendTimeout === 'function') {
285
+ entry.extendTimeout(disconnectionDurationMs);
286
+ }
287
+ }
288
+ }
289
+ }
233
290
  });
234
291
 
235
292
  this._ably.connection.on("suspended", function () {
@@ -267,12 +324,14 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
267
324
  /**
268
325
  * Recover missed messages after a channel discontinuity.
269
326
  * Uses historyBeforeSubscribe() on each subscription, which guarantees
270
- * no gap between historical and live messages.
327
+ * no gap between historical and live messages. Each recovered message
328
+ * is dispatched through the same handler that processes live messages
329
+ * so that pending promises are resolved/rejected correctly.
271
330
  */
272
331
  async _recoverFromDiscontinuity() {
273
332
  var subs = [
274
- { name: 'response', sub: this._responseSubscription },
275
- { name: 'file', sub: this._fileSubscription },
333
+ { name: 'response', sub: this._responseSubscription, handler: this._onResponseMsg },
334
+ { name: 'file', sub: this._fileSubscription, handler: this._onFileMsg },
276
335
  ];
277
336
  var totalRecovered = 0;
278
337
  for (var i = 0; i < subs.length; i++) {
@@ -283,17 +342,29 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
283
342
  var page = await entry.sub.historyBeforeSubscribe({ limit: 100 });
284
343
  var recovered = 0;
285
344
  while (page) {
286
- recovered += page.items.length;
345
+ // Replay each missed message through the handler so pending
346
+ // promises get resolved instead of timing out.
347
+ for (var j = 0; j < page.items.length; j++) {
348
+ recovered++;
349
+ try {
350
+ if (entry.handler) {
351
+ logger.log('[ably] Replaying recovered ' + entry.name + ' message (requestId=' + (page.items[j].data && page.items[j].data.requestId || 'none') + ')');
352
+ entry.handler(page.items[j]);
353
+ }
354
+ } catch (replayErr) {
355
+ logger.error('[ably] Error replaying recovered message: ' + (replayErr.message || replayErr));
356
+ }
357
+ }
287
358
  page = page.hasNext() ? await page.next() : null;
288
359
  }
289
360
  totalRecovered += recovered;
290
- logger.log('[ably] Discontinuity recovery: found ' + recovered + ' ' + entry.name + ' message(s) in gap');
361
+ logger.log('[ably] Discontinuity recovery: replayed ' + recovered + ' ' + entry.name + ' message(s) from gap');
291
362
  } catch (err) {
292
363
  logger.error('[ably] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
293
364
  }
294
365
  }
295
366
  if (totalRecovered > 0) {
296
- logger.warn('[ably] Recovered ' + totalRecovered + ' message(s) that were missed during connection interruption');
367
+ logger.warn('[ably] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
297
368
  } else {
298
369
  logger.log('[ably] Discontinuity recovery: no missed messages found');
299
370
  }
@@ -780,8 +851,25 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
780
851
 
781
852
  var requestId = message.requestId;
782
853
 
783
- var timeoutId = setTimeout(function () {
854
+ // timeoutId and timeoutExpiresAt are declared as vars so they can be
855
+ // updated by extendTimeout() (closure mutation).
856
+ var timeoutId;
857
+ var timeoutExpiresAt;
858
+
859
+ var timeoutFn = function () {
784
860
  if (self.ps[requestId]) {
861
+ var pendingIds = Object.keys(self.ps);
862
+ var pendingSummary = pendingIds.map(function (rid) {
863
+ var e = self.ps[rid];
864
+ var age = e && e.startTime ? ((Date.now() - e.startTime) / 1000).toFixed(1) + 's' : '?';
865
+ return rid + '(' + (e && e.message ? e.message.type : '?') + ', ' + age + ')';
866
+ }).join(', ');
867
+ logger.error(
868
+ '[ably] Promise TIMEOUT: requestId=' + requestId +
869
+ ' | type=' + message.type +
870
+ ' | timeout=' + timeout + 'ms' +
871
+ ' | all pending: [' + pendingSummary + ']'
872
+ );
785
873
  delete self.ps[requestId];
786
874
  delete self._execBuffers[requestId];
787
875
  rejectPromise(
@@ -794,7 +882,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
794
882
  ),
795
883
  );
796
884
  }
797
- }, timeout);
885
+ };
886
+
887
+ timeoutId = setTimeout(timeoutFn, timeout);
888
+ timeoutExpiresAt = Date.now() + timeout;
798
889
  if (timeoutId.unref) timeoutId.unref();
799
890
 
800
891
  this.ps[requestId] = {
@@ -807,6 +898,26 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
807
898
  clearTimeout(timeoutId);
808
899
  rejectPromise(error);
809
900
  },
901
+ /**
902
+ * Extend the pending timeout by disconnectionDurationMs — called on Ably reconnect
903
+ * to compensate for time spent disconnected.
904
+ */
905
+ extendTimeout: function (disconnectionDurationMs) {
906
+ clearTimeout(timeoutId);
907
+ // Clamp remaining to 0 so a command whose timer expired during the
908
+ // outage still gets the full disconnection duration as its new budget.
909
+ var remaining = Math.max(0, timeoutExpiresAt - Date.now());
910
+ // Minimum 5s remaining after extension to allow the response to arrive.
911
+ var MIN_REMAINING_MS = 5000;
912
+ var newRemaining = Math.max(remaining + disconnectionDurationMs, MIN_REMAINING_MS);
913
+ timeoutExpiresAt = Date.now() + newRemaining;
914
+ timeoutId = setTimeout(timeoutFn, newRemaining);
915
+ if (timeoutId.unref) timeoutId.unref();
916
+ logger.log(
917
+ '[ably] Extended timeout for requestId=' + requestId +
918
+ ' by ' + disconnectionDurationMs + 'ms (new remaining: ' + Math.round(newRemaining / 1000) + 's)'
919
+ );
920
+ },
810
921
  message: message,
811
922
  startTime: Date.now(),
812
923
  };
@@ -42,14 +42,14 @@ function checkVitestVersion() {
42
42
  if (major < MINIMUM_VITEST_VERSION) {
43
43
  throw new Error(
44
44
  `TestDriver requires Vitest >= ${MINIMUM_VITEST_VERSION}.0.0, but found ${version}. ` +
45
- `Please upgrade Vitest: npm install vitest@latest`,
45
+ `Please upgrade Vitest: npm install vitest@latest`,
46
46
  );
47
47
  }
48
48
  } catch (err) {
49
49
  if (err.code === "MODULE_NOT_FOUND") {
50
50
  throw new Error(
51
51
  "TestDriver requires Vitest to be installed. " +
52
- "Please install it: npm install vitest@latest",
52
+ "Please install it: npm install vitest@latest",
53
53
  );
54
54
  }
55
55
  throw err;
@@ -646,7 +646,7 @@ export function TestDriver(context, options = {}) {
646
646
 
647
647
  // Wait for connection to finish if it was initiated
648
648
  if (currentInstance.__connectionPromise) {
649
- await currentInstance.__connectionPromise.catch(() => {}); // Ignore connection errors during cleanup
649
+ await currentInstance.__connectionPromise.catch(() => { }); // Ignore connection errors during cleanup
650
650
  }
651
651
 
652
652
  // Disconnect with timeout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/mcp",
3
- "version": "7.8.0-canary.12",
3
+ "version": "7.8.0-canary.13",
4
4
  "description": "Next generation autonomous AI agent for end-to-end testing of web & desktop",
5
5
  "main": "sdk.js",
6
6
  "types": "sdk.d.ts",