@testdriverai/mcp 7.9.103-canary → 7.9.104-test

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.
@@ -266,6 +266,102 @@ function requireActiveSession() {
266
266
  sessionManager.refreshSession(session.sessionId);
267
267
  return { valid: true };
268
268
  }
269
+ const DEFAULT_HEARTBEAT_MS = 3000;
270
+ function makeProgressReporter(extra) {
271
+ const progressToken = extra?._meta?.progressToken;
272
+ // No token → client did not opt into progress. Return a no-op reporter.
273
+ if (progressToken === undefined || progressToken === null) {
274
+ return {
275
+ report: () => { },
276
+ heartbeat: () => () => { },
277
+ };
278
+ }
279
+ let progress = 0;
280
+ const send = (message) => {
281
+ progress += 1;
282
+ // Fire-and-forget: a failed notification must never break the tool call.
283
+ void extra
284
+ .sendNotification({
285
+ method: "notifications/progress",
286
+ params: { progressToken, progress, message },
287
+ })
288
+ .catch((err) => {
289
+ logger.debug("progress: sendNotification failed", { error: String(err) });
290
+ });
291
+ };
292
+ return {
293
+ report: (message) => send(message),
294
+ heartbeat: (message, intervalMs = DEFAULT_HEARTBEAT_MS) => {
295
+ send(message);
296
+ const timer = setInterval(() => send(message), intervalMs);
297
+ // Don't let the heartbeat keep the event loop alive on its own.
298
+ timer.unref?.();
299
+ return () => clearInterval(timer);
300
+ },
301
+ };
302
+ }
303
+ // =============================================================================
304
+ // Cancellation (MCP `notifications/cancelled` → `extra.signal`)
305
+ // =============================================================================
306
+ /** Thrown when a tool call is aborted by the client. */
307
+ class ToolAbortError extends Error {
308
+ constructor(tool) {
309
+ super(`${tool} was cancelled by the client`);
310
+ this.name = "ToolAbortError";
311
+ }
312
+ }
313
+ /** Reject as soon as `signal` aborts. Used to race against long SDK calls. */
314
+ function rejectOnAbort(signal, tool) {
315
+ if (!signal) {
316
+ // Never-resolving promise with a no-op cleanup — Promise.race ignores it.
317
+ return { promise: new Promise(() => { }), cleanup: () => { } };
318
+ }
319
+ let onAbort = () => { };
320
+ const promise = new Promise((_, reject) => {
321
+ onAbort = () => reject(new ToolAbortError(tool));
322
+ if (signal.aborted) {
323
+ onAbort();
324
+ }
325
+ else {
326
+ signal.addEventListener("abort", onAbort, { once: true });
327
+ }
328
+ });
329
+ return { promise, cleanup: () => signal.removeEventListener("abort", onAbort) };
330
+ }
331
+ /**
332
+ * Run a long-running SDK call, but settle as soon as the client aborts.
333
+ *
334
+ * The wrapped SDK methods are not themselves signal-aware, so on abort the
335
+ * underlying work keeps running to completion in the background — but the tool
336
+ * call returns promptly with a `ToolAbortError` instead of blocking the client.
337
+ * Callers that hold cleanable resources (e.g. `session_start`) should catch
338
+ * `ToolAbortError` and tear them down.
339
+ */
340
+ async function raceAbort(signal, tool, work) {
341
+ if (signal?.aborted) {
342
+ throw new ToolAbortError(tool);
343
+ }
344
+ const { promise, cleanup } = rejectOnAbort(signal, tool);
345
+ try {
346
+ return await Promise.race([work, promise]);
347
+ }
348
+ finally {
349
+ cleanup();
350
+ }
351
+ }
352
+ /**
353
+ * If `error` is a client cancellation, return a "cancelled" tool result so the
354
+ * caller can `return` it; otherwise return null so normal error handling (log +
355
+ * Sentry + rethrow) proceeds. Keeps abort out of error reporting — a user
356
+ * cancelling is not a failure.
357
+ */
358
+ function cancelledResultOrNull(error, tool) {
359
+ if (error instanceof ToolAbortError) {
360
+ logger.info(`${tool}: Cancelled by client`);
361
+ return createToolResult(false, `${tool} was cancelled.`, { action: tool, cancelled: true });
362
+ }
363
+ return null;
364
+ }
269
365
  /**
270
366
  * Create tool result with structured content for MCP App
271
367
  * Images: imageUrl (data URL) goes to structuredContent for UI to display
@@ -411,8 +507,9 @@ Debug mode (connect to existing sandbox):
411
507
  - Use this to interactively debug failed tests without re-running from scratch`,
412
508
  inputSchema: SessionStartInputSchema,
413
509
  _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
414
- }, async (params) => {
510
+ }, async (params, extra) => {
415
511
  const startTime = Date.now();
512
+ const progress = makeProgressReporter(extra);
416
513
  // Resolve OS with priority: explicit param > TD_OS env var > "linux" default
417
514
  // This mirrors the behavior of the Vitest hooks (hooks.mjs) which also reads TD_OS
418
515
  const { os: resolvedOs, warning: osWarning } = resolveOs(params.os);
@@ -495,10 +592,16 @@ Debug mode (connect to existing sandbox):
495
592
  // Handle sandboxId mode - connect to existing sandbox (debug-on-failure mode)
496
593
  if (params.sandboxId) {
497
594
  logger.info("session_start: Connecting to existing sandbox (debug mode)", { sandboxId: params.sandboxId });
498
- await sdk.connect({
499
- sandboxId: params.sandboxId,
500
- keepAlive: params.keepAlive,
501
- });
595
+ const stopHeartbeat = progress.heartbeat(`Connecting to existing sandbox ${params.sandboxId}...`);
596
+ try {
597
+ await raceAbort(extra.signal, "session_start", sdk.connect({
598
+ sandboxId: params.sandboxId,
599
+ keepAlive: params.keepAlive,
600
+ }));
601
+ }
602
+ finally {
603
+ stopHeartbeat();
604
+ }
502
605
  // Get sandbox ID
503
606
  const instance = sdk.getInstance();
504
607
  logger.info("session_start: Connected to existing sandbox", { instanceId: instance?.instanceId });
@@ -507,7 +610,8 @@ Debug mode (connect to existing sandbox):
507
610
  setSessionContext(newSession.sessionId, instance?.instanceId);
508
611
  // Capture screenshot of current state
509
612
  logger.debug("session_start: Capturing screenshot of existing sandbox");
510
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
613
+ progress.report("Capturing screenshot...");
614
+ const screenshotBase64 = await raceAbort(extra.signal, "session_start", sdk.agent.system.captureScreenBase64(1, false, true));
511
615
  let screenshotResourceUri;
512
616
  if (screenshotBase64) {
513
617
  screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
@@ -536,11 +640,19 @@ You are now connected to the sandbox in its current state. Use find, click, type
536
640
  else {
537
641
  logger.info("session_start: Connecting to cloud sandbox...");
538
642
  }
539
- await sdk.connect({
540
- reconnect: params.reconnect,
541
- keepAlive: params.keepAlive,
542
- ip: instanceIp,
543
- });
643
+ {
644
+ const stopHeartbeat = progress.heartbeat(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
645
+ try {
646
+ await raceAbort(extra.signal, "session_start", sdk.connect({
647
+ reconnect: params.reconnect,
648
+ keepAlive: params.keepAlive,
649
+ ip: instanceIp,
650
+ }));
651
+ }
652
+ finally {
653
+ stopHeartbeat();
654
+ }
655
+ }
544
656
  // Get sandbox ID
545
657
  const instance = sdk.getInstance();
546
658
  logger.info("session_start: Connected to sandbox", { instanceId: instance?.instanceId });
@@ -550,52 +662,61 @@ You are now connected to the sandbox in its current state. Use find, click, type
550
662
  // Get provision-specific options
551
663
  const provisionOptions = getProvisionOptions(params);
552
664
  let provisionCmd = "";
553
- // Provision based on type
554
- switch (params.type) {
555
- case "chrome": {
556
- const chromeOpts = provisionOptions;
557
- logger.info("session_start: Provisioning Chrome", { url: chromeOpts.url });
558
- await sdk.provision.chrome(chromeOpts);
559
- provisionCmd = "provision.chrome";
560
- logger.debug("session_start: Chrome provisioned");
561
- break;
562
- }
563
- case "chromeExtension": {
564
- const extOpts = provisionOptions;
565
- logger.info("session_start: Provisioning Chrome Extension", { extensionPath: extOpts.extensionPath, extensionId: extOpts.extensionId });
566
- await sdk.provision.chromeExtension(extOpts);
567
- provisionCmd = "provision.chromeExtension";
568
- logger.debug("session_start: Chrome Extension provisioned");
569
- break;
570
- }
571
- case "vscode": {
572
- const vscodeOpts = provisionOptions;
573
- logger.info("session_start: Provisioning VS Code", { workspace: vscodeOpts.workspace });
574
- await sdk.provision.vscode(vscodeOpts);
575
- provisionCmd = "provision.vscode";
576
- logger.debug("session_start: VS Code provisioned");
577
- break;
578
- }
579
- case "installer": {
580
- const installerOpts = provisionOptions;
581
- logger.info("session_start: Provisioning installer", { url: installerOpts.url });
582
- await sdk.provision.installer(installerOpts);
583
- provisionCmd = "provision.installer";
584
- logger.debug("session_start: Installer provisioned");
585
- break;
586
- }
587
- case "electron": {
588
- const electronOpts = provisionOptions;
589
- logger.info("session_start: Provisioning Electron", { appPath: electronOpts.appPath });
590
- await sdk.provision.electron(electronOpts);
591
- provisionCmd = "provision.electron";
592
- logger.debug("session_start: Electron app provisioned");
593
- break;
665
+ // Provisioning can take tens of seconds (downloading installers, booting
666
+ // apps). Heartbeat so the client's idle timeout keeps resetting.
667
+ const stopProvisionHeartbeat = progress.heartbeat(`Provisioning ${params.type}...`);
668
+ try {
669
+ // Provision based on type
670
+ switch (params.type) {
671
+ case "chrome": {
672
+ const chromeOpts = provisionOptions;
673
+ logger.info("session_start: Provisioning Chrome", { url: chromeOpts.url });
674
+ await raceAbort(extra.signal, "session_start", sdk.provision.chrome(chromeOpts));
675
+ provisionCmd = "provision.chrome";
676
+ logger.debug("session_start: Chrome provisioned");
677
+ break;
678
+ }
679
+ case "chromeExtension": {
680
+ const extOpts = provisionOptions;
681
+ logger.info("session_start: Provisioning Chrome Extension", { extensionPath: extOpts.extensionPath, extensionId: extOpts.extensionId });
682
+ await raceAbort(extra.signal, "session_start", sdk.provision.chromeExtension(extOpts));
683
+ provisionCmd = "provision.chromeExtension";
684
+ logger.debug("session_start: Chrome Extension provisioned");
685
+ break;
686
+ }
687
+ case "vscode": {
688
+ const vscodeOpts = provisionOptions;
689
+ logger.info("session_start: Provisioning VS Code", { workspace: vscodeOpts.workspace });
690
+ await raceAbort(extra.signal, "session_start", sdk.provision.vscode(vscodeOpts));
691
+ provisionCmd = "provision.vscode";
692
+ logger.debug("session_start: VS Code provisioned");
693
+ break;
694
+ }
695
+ case "installer": {
696
+ const installerOpts = provisionOptions;
697
+ logger.info("session_start: Provisioning installer", { url: installerOpts.url });
698
+ await raceAbort(extra.signal, "session_start", sdk.provision.installer(installerOpts));
699
+ provisionCmd = "provision.installer";
700
+ logger.debug("session_start: Installer provisioned");
701
+ break;
702
+ }
703
+ case "electron": {
704
+ const electronOpts = provisionOptions;
705
+ logger.info("session_start: Provisioning Electron", { appPath: electronOpts.appPath });
706
+ await raceAbort(extra.signal, "session_start", sdk.provision.electron(electronOpts));
707
+ provisionCmd = "provision.electron";
708
+ logger.debug("session_start: Electron app provisioned");
709
+ break;
710
+ }
594
711
  }
595
712
  }
713
+ finally {
714
+ stopProvisionHeartbeat();
715
+ }
596
716
  // Capture initial screenshot after provisioning
597
717
  logger.debug("session_start: Capturing initial screenshot");
598
- const screenshotBase64 = await sdk.agent.system.captureScreenBase64(1, false, true);
718
+ progress.report("Capturing screenshot...");
719
+ const screenshotBase64 = await raceAbort(extra.signal, "session_start", sdk.agent.system.captureScreenBase64(1, false, true));
599
720
  let screenshotResourceUri;
600
721
  if (screenshotBase64) {
601
722
  screenshotResourceUri = storeImage(screenshotBase64, "screenshot");
@@ -632,6 +753,19 @@ IMPORTANT - If creating a new test project, use these EXACT dependencies in pack
632
753
  }, generatedCode);
633
754
  }
634
755
  catch (error) {
756
+ // On client cancellation, tear down the half-provisioned session so we
757
+ // don't leak a connected sandbox. The underlying SDK call may still be
758
+ // running in the background; best-effort cleanup is all we can do.
759
+ if (error instanceof ToolAbortError) {
760
+ logger.info("session_start: Cancelled by client, tearing down session");
761
+ try {
762
+ await sdk?.disconnect?.();
763
+ }
764
+ catch (cleanupErr) {
765
+ logger.warn("session_start: Cleanup after cancel failed", { error: String(cleanupErr) });
766
+ }
767
+ return createToolResult(false, "Session start was cancelled.", { action: "session_start", cancelled: true });
768
+ }
635
769
  logger.error("session_start: Failed", { error: String(error) });
636
770
  captureException(error, { tags: { tool: "session_start" }, extra: { params } });
637
771
  throw error;
@@ -693,8 +827,9 @@ registerAppTool(server, "find", {
693
827
  timeout: z.number().optional().describe("Timeout in ms for polling"),
694
828
  }),
695
829
  _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
696
- }, async (params) => {
830
+ }, async (params, extra) => {
697
831
  const startTime = Date.now();
832
+ const progress = makeProgressReporter(extra);
698
833
  logger.info("find: Starting", { description: params.description, timeout: params.timeout });
699
834
  const sessionCheck = requireActiveSession();
700
835
  if (!sessionCheck.valid) {
@@ -703,7 +838,14 @@ registerAppTool(server, "find", {
703
838
  }
704
839
  try {
705
840
  logger.debug("find: Calling SDK find");
706
- const element = await sdk.find(params.description, params.timeout ? { timeout: params.timeout } : undefined);
841
+ const stopHeartbeat = progress.heartbeat(`Looking for "${params.description}"...`);
842
+ let element;
843
+ try {
844
+ element = await raceAbort(extra.signal, "find", sdk.find(params.description, params.timeout ? { timeout: params.timeout } : undefined));
845
+ }
846
+ finally {
847
+ stopHeartbeat();
848
+ }
707
849
  const found = element.found();
708
850
  const coords = element.getCoordinates();
709
851
  // Store element ref for later use (stores actual Element instance)
@@ -784,6 +926,9 @@ registerAppTool(server, "find", {
784
926
  }, generatedCode);
785
927
  }
786
928
  catch (error) {
929
+ const cancelled = cancelledResultOrNull(error, "find");
930
+ if (cancelled)
931
+ return cancelled;
787
932
  logger.error("find: Failed", { error: String(error), description: params.description });
788
933
  captureException(error, { tags: { tool: "find" }, extra: { description: params.description } });
789
934
  throw error;
@@ -798,8 +943,9 @@ registerAppTool(server, "findall", {
798
943
  timeout: z.number().optional().describe("Timeout in ms for polling"),
799
944
  }),
800
945
  _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
801
- }, async (params) => {
946
+ }, async (params, extra) => {
802
947
  const startTime = Date.now();
948
+ const progress = makeProgressReporter(extra);
803
949
  logger.info("findall: Starting", { description: params.description, timeout: params.timeout });
804
950
  const sessionCheck = requireActiveSession();
805
951
  if (!sessionCheck.valid) {
@@ -808,7 +954,14 @@ registerAppTool(server, "findall", {
808
954
  }
809
955
  try {
810
956
  logger.debug("findall: Calling SDK findAll");
811
- const elements = await sdk.findAll(params.description, params.timeout ? { timeout: params.timeout } : undefined);
957
+ const stopHeartbeat = progress.heartbeat(`Looking for all "${params.description}"...`);
958
+ let elements;
959
+ try {
960
+ elements = await raceAbort(extra.signal, "findall", sdk.findAll(params.description, params.timeout ? { timeout: params.timeout } : undefined));
961
+ }
962
+ finally {
963
+ stopHeartbeat();
964
+ }
812
965
  const count = elements.length;
813
966
  // Store element refs for later use
814
967
  const refs = [];
@@ -893,6 +1046,9 @@ registerAppTool(server, "findall", {
893
1046
  }, generatedCode);
894
1047
  }
895
1048
  catch (error) {
1049
+ const cancelled = cancelledResultOrNull(error, "findall");
1050
+ if (cancelled)
1051
+ return cancelled;
896
1052
  logger.error("findall: Failed", { error: String(error), description: params.description });
897
1053
  captureException(error, { tags: { tool: "findall" }, extra: { description: params.description } });
898
1054
  throw error;
@@ -1089,8 +1245,9 @@ registerAppTool(server, "find_and_click", {
1089
1245
  action: z.enum(["click", "double-click", "right-click"]).default("click"),
1090
1246
  }),
1091
1247
  _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
1092
- }, async (params) => {
1248
+ }, async (params, extra) => {
1093
1249
  const startTime = Date.now();
1250
+ const progress = makeProgressReporter(extra);
1094
1251
  logger.info("find_and_click: Starting", { description: params.description, action: params.action });
1095
1252
  const sessionCheck = requireActiveSession();
1096
1253
  if (!sessionCheck.valid) {
@@ -1099,7 +1256,14 @@ registerAppTool(server, "find_and_click", {
1099
1256
  }
1100
1257
  try {
1101
1258
  logger.debug("find_and_click: Finding element");
1102
- const element = await sdk.find(params.description);
1259
+ const stopHeartbeat = progress.heartbeat(`Looking for "${params.description}"...`);
1260
+ let element;
1261
+ try {
1262
+ element = await raceAbort(extra.signal, "find_and_click", sdk.find(params.description));
1263
+ }
1264
+ finally {
1265
+ stopHeartbeat();
1266
+ }
1103
1267
  const found = element.found();
1104
1268
  if (!found) {
1105
1269
  logger.warn("find_and_click: Element not found", { description: params.description });
@@ -1207,6 +1371,9 @@ registerAppTool(server, "find_and_click", {
1207
1371
  }, generatedCode);
1208
1372
  }
1209
1373
  catch (error) {
1374
+ const cancelled = cancelledResultOrNull(error, "find_and_click");
1375
+ if (cancelled)
1376
+ return cancelled;
1210
1377
  logger.error("find_and_click: Failed", { error: String(error), description: params.description });
1211
1378
  captureException(error, { tags: { tool: "find_and_click" }, extra: { description: params.description, action: params.action } });
1212
1379
  throw error;
@@ -1364,8 +1531,9 @@ You can optionally provide a reference image URI to compare against a previous s
1364
1531
  referenceImageUri: z.string().optional().describe("Optional screenshot resource URI (e.g., 'screenshot://testdriver/screenshot/screenshot-1') to compare against instead of the automatically captured 'before' screenshot. Use a screenshotResourceUri from a previous action."),
1365
1532
  }),
1366
1533
  _meta: { ui: { resourceUri: RESOURCE_URI, expanded: true } },
1367
- }, async (params) => {
1534
+ }, async (params, extra) => {
1368
1535
  const startTime = Date.now();
1536
+ const progress = makeProgressReporter(extra);
1369
1537
  logger.info("check: Starting", { task: params.task, hasReferenceImageUri: !!params.referenceImageUri });
1370
1538
  const sessionCheck = requireActiveSession();
1371
1539
  if (!sessionCheck.valid) {
@@ -1375,6 +1543,7 @@ You can optionally provide a reference image URI to compare against a previous s
1375
1543
  try {
1376
1544
  // Capture current screenshot
1377
1545
  logger.debug("check: Capturing current screenshot");
1546
+ progress.report("Capturing screenshot...");
1378
1547
  const currentScreenshot = await sdk.agent.system.captureScreenBase64(1, false, true);
1379
1548
  // Use provided reference image URI, last screenshot as "before" state, or current if no previous screenshot
1380
1549
  let beforeScreenshot;
@@ -1425,12 +1594,19 @@ You can optionally provide a reference image URI to compare against a previous s
1425
1594
  beforeScreenshotPreview: beforeScreenshot?.substring(0, 50),
1426
1595
  currentScreenshotPreview: currentScreenshot?.substring(0, 50)
1427
1596
  });
1428
- const response = await sdk.agent.sdk.req("check", {
1429
- tasks: [params.task],
1430
- images: [beforeScreenshot, currentScreenshot],
1431
- mousePosition,
1432
- activeWindow,
1433
- });
1597
+ const stopHeartbeat = progress.heartbeat(`Checking: "${params.task}"...`);
1598
+ let response;
1599
+ try {
1600
+ response = await raceAbort(extra.signal, "check", sdk.agent.sdk.req("check", {
1601
+ tasks: [params.task],
1602
+ images: [beforeScreenshot, currentScreenshot],
1603
+ mousePosition,
1604
+ activeWindow,
1605
+ }));
1606
+ }
1607
+ finally {
1608
+ stopHeartbeat();
1609
+ }
1434
1610
  const aiResponse = response.data;
1435
1611
  // Store screenshot for resource serving
1436
1612
  let screenshotResourceUri;
@@ -1460,6 +1636,9 @@ You can optionally provide a reference image URI to compare against a previous s
1460
1636
  });
1461
1637
  }
1462
1638
  catch (error) {
1639
+ const cancelled = cancelledResultOrNull(error, "check");
1640
+ if (cancelled)
1641
+ return cancelled;
1463
1642
  logger.error("check: Failed", { error: String(error), task: params.task });
1464
1643
  captureException(error, { tags: { tool: "check" }, extra: { task: params.task } });
1465
1644
  throw error;