@testdriverai/mcp 7.11.6-test → 7.11.7-canary
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/mcp-server/dist/core/actions.d.ts +68 -7
- package/mcp-server/dist/core/actions.js +153 -91
- package/mcp-server/dist/server.mjs +1102 -993
- package/mcp-server/src/core/actions.ts +204 -100
- package/mcp-server/src/server.ts +206 -63
- package/package.json +1 -1
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
* mirroring how the stdio MCP server has always behaved.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
21
22
|
import { generateActionCode } from "../codegen.js";
|
|
22
23
|
import { getProvisionOptions, SessionStartInputSchema, type SessionStartInput } from "../provision-types.js";
|
|
23
|
-
import { sessionManager, type SessionState } from "../session.js";
|
|
24
|
+
import { SessionManager, sessionManager as globalSessionManager, type SessionState } from "../session.js";
|
|
24
25
|
|
|
25
26
|
// Re-export the input contract so adapters (MCP server, eve tools) share one
|
|
26
27
|
// source of truth for the session_start schema instead of redeclaring it.
|
|
@@ -62,21 +63,120 @@ export class NoActiveSessionError extends Error {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
// =============================================================================
|
|
65
|
-
//
|
|
66
|
+
// Per-connection state (isolated via AsyncLocalStorage)
|
|
66
67
|
// =============================================================================
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The mutable state a single sandbox session needs: the live SDK handle, the
|
|
71
|
+
* element-ref map from `find`/`findall`, the "last screenshot" `check` diffs
|
|
72
|
+
* against, the adapter recovery hook, the in-flight reconnect promise, and the
|
|
73
|
+
* session-lifecycle manager.
|
|
74
|
+
*
|
|
75
|
+
* This used to be a bag of module-level singletons ("single sandbox per
|
|
76
|
+
* process"). That is correct for hosts that ARE one-session-per-process — eve
|
|
77
|
+
* (each durable turn is its own recycled process) and the stdio MCP server — but
|
|
78
|
+
* the Streamable-HTTP MCP server is one long-lived process serving *concurrent*
|
|
79
|
+
* clients, where shared singletons let one client's tool call read another
|
|
80
|
+
* client's sandbox. So the state moves into {@link CoreContext} and is resolved
|
|
81
|
+
* per async call via {@link als}.
|
|
82
|
+
*/
|
|
83
|
+
export interface CoreContext {
|
|
84
|
+
sdk: any;
|
|
85
|
+
lastScreenshotBase64: string | null;
|
|
86
|
+
reconnectResolver: ReconnectResolver | null;
|
|
87
|
+
/** In-flight reconnect, so concurrent actions in one context share one rebuild. */
|
|
88
|
+
reconnecting: Promise<void> | null;
|
|
89
|
+
/** Stored element instances from `find`/`findall`, addressable by ref. */
|
|
90
|
+
elementRefs: Map<
|
|
91
|
+
string,
|
|
92
|
+
{ element: any; description: string; coords: { x: number; y: number; centerX: number; centerY: number } }
|
|
93
|
+
>;
|
|
94
|
+
/** Session lifecycle for THIS context. */
|
|
95
|
+
sessions: SessionManager;
|
|
96
|
+
/**
|
|
97
|
+
* Opaque per-connection scratch space for adapters (e.g. the MCP server's
|
|
98
|
+
* image store). mcp-core doesn't read this; it just guarantees each isolated
|
|
99
|
+
* context gets its own object, so adapter-side per-connection state rides along
|
|
100
|
+
* with the same AsyncLocalStorage isolation instead of needing a second one.
|
|
101
|
+
*/
|
|
102
|
+
adapter: Record<string, unknown>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Build a fresh, empty context. Callers that want isolation mint one per connection. */
|
|
106
|
+
export function createCoreContext(): CoreContext {
|
|
107
|
+
return {
|
|
108
|
+
sdk: null,
|
|
109
|
+
lastScreenshotBase64: null,
|
|
110
|
+
reconnectResolver: null,
|
|
111
|
+
reconnecting: null,
|
|
112
|
+
elementRefs: new Map(),
|
|
113
|
+
// Reuse the shared SessionManager for the global context so that consumers
|
|
114
|
+
// still importing `sessionManager` from ../session observe the same state;
|
|
115
|
+
// isolated contexts get their own instance.
|
|
116
|
+
sessions: globalSessionManager,
|
|
117
|
+
adapter: {},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const als = new AsyncLocalStorage<CoreContext>();
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The fallback context used when no isolated context is active — i.e. for eve
|
|
125
|
+
* and the stdio MCP server, which are one-session-per-process and never call
|
|
126
|
+
* {@link runInContext}. Behavior for those hosts is byte-identical to the old
|
|
127
|
+
* module-global singletons.
|
|
128
|
+
*/
|
|
129
|
+
const globalContext: CoreContext = createCoreContext();
|
|
130
|
+
|
|
131
|
+
/** Resolve the active context: the ALS store if one is running, else the global. */
|
|
132
|
+
function ctx(): CoreContext {
|
|
133
|
+
return als.getStore() ?? globalContext;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Run `fn` with `context` as the active {@link CoreContext}, isolating every
|
|
138
|
+
* action's state (sdk, element refs, session, recovery hook) for the duration.
|
|
139
|
+
* The HTTP MCP server wraps each connection's request handling in this so
|
|
140
|
+
* concurrent clients never share a sandbox. Hosts that don't call this keep
|
|
141
|
+
* using {@link globalContext} exactly as before.
|
|
142
|
+
*/
|
|
143
|
+
export function runInContext<T>(context: CoreContext, fn: () => T): T {
|
|
144
|
+
return als.run(context, fn);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Build an isolated context whose SessionManager is its own (not the global). */
|
|
148
|
+
export function createIsolatedContext(): CoreContext {
|
|
149
|
+
const c = createCoreContext();
|
|
150
|
+
c.sessions = new SessionManager();
|
|
151
|
+
return c;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The active context's adapter scratch space (see {@link CoreContext.adapter}). */
|
|
155
|
+
export function getAdapterState(): Record<string, unknown> {
|
|
156
|
+
return ctx().adapter;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The active context's current session (null when none). Adapters use this for
|
|
160
|
+
* read-only lookups (e.g. the MCP server's testFile/expiry) that must resolve
|
|
161
|
+
* the caller's own session, not a process-global one. */
|
|
162
|
+
export function getCurrentSession(): SessionState | null {
|
|
163
|
+
return ctx().sessions.getCurrentSession();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Time remaining (ms) on a session in the active context. */
|
|
167
|
+
export function getSessionTimeRemaining(sessionId: string): number {
|
|
168
|
+
return ctx().sessions.getTimeRemaining(sessionId);
|
|
169
|
+
}
|
|
70
170
|
|
|
71
171
|
/**
|
|
72
|
-
* Optional adapter-supplied recovery hook. When the
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
172
|
+
* Optional adapter-supplied recovery hook. When the SDK is missing or stale at
|
|
173
|
+
* action time, {@link requireActiveSession} calls this to obtain the params
|
|
174
|
+
* needed to rebuild the connection from the still-alive sandbox, then reconnects
|
|
175
|
+
* before throwing NO_SESSION.
|
|
76
176
|
*
|
|
77
177
|
* Why a hook instead of mcp-core owning the handle: the durable facts (sandbox
|
|
78
178
|
* id, config) and the API key must survive a host process recycle, but this
|
|
79
|
-
*
|
|
179
|
+
* context's state does NOT — it resets on the very recycle we're recovering
|
|
80
180
|
* from. So the *durable owner* (eve's per-session state) registers a resolver
|
|
81
181
|
* that reads its own durable store and re-resolves the key per call. Hosts with
|
|
82
182
|
* one long-lived process (stdio MCP server, CLI) register nothing: the resolver
|
|
@@ -85,38 +185,28 @@ let lastScreenshotBase64: string | null = null;
|
|
|
85
185
|
*
|
|
86
186
|
* The resolver returns `null` when it has nothing to recover from (no sandbox
|
|
87
187
|
* provisioned this session), in which case we fall through to the normal throw.
|
|
88
|
-
* It is re-registered each step by the adapter (
|
|
188
|
+
* It is re-registered each step by the adapter (context state resets on recycle),
|
|
89
189
|
* so a stale closure can't outlive the process it was bound to.
|
|
90
190
|
*/
|
|
91
191
|
export type ReconnectResolver = () => Promise<ReconnectParams | null>;
|
|
92
|
-
let reconnectResolver: ReconnectResolver | null = null;
|
|
93
192
|
|
|
94
193
|
/**
|
|
95
194
|
* Register (or clear, with `null`) the recovery hook. Durable adapters call this
|
|
96
195
|
* at the start of each step with a resolver bound to the current tool context.
|
|
97
196
|
*/
|
|
98
197
|
export function setReconnectResolver(resolver: ReconnectResolver | null): void {
|
|
99
|
-
reconnectResolver = resolver;
|
|
198
|
+
ctx().reconnectResolver = resolver;
|
|
100
199
|
}
|
|
101
200
|
|
|
102
|
-
/** In-flight reconnect, so concurrent actions in one step share one rebuild. */
|
|
103
|
-
let reconnecting: Promise<void> | null = null;
|
|
104
|
-
|
|
105
|
-
/** Stored element instances from `find`/`findall`, addressable by ref. */
|
|
106
|
-
const elementRefs = new Map<
|
|
107
|
-
string,
|
|
108
|
-
{ element: any; description: string; coords: { x: number; y: number; centerX: number; centerY: number } }
|
|
109
|
-
>();
|
|
110
|
-
|
|
111
201
|
/** Expose internals the adapters legitimately need (read-only intent). */
|
|
112
202
|
export function getSdk(): any {
|
|
113
|
-
return sdk;
|
|
203
|
+
return ctx().sdk;
|
|
114
204
|
}
|
|
115
205
|
export function getLastScreenshotBase64(): string | null {
|
|
116
|
-
return lastScreenshotBase64;
|
|
206
|
+
return ctx().lastScreenshotBase64;
|
|
117
207
|
}
|
|
118
208
|
export function getElementRef(ref: string) {
|
|
119
|
-
return elementRefs.get(ref);
|
|
209
|
+
return ctx().elementRefs.get(ref);
|
|
120
210
|
}
|
|
121
211
|
|
|
122
212
|
/**
|
|
@@ -127,8 +217,9 @@ export function getElementRef(ref: string) {
|
|
|
127
217
|
* does NOT refresh the keepAlive window — it only reports liveness.
|
|
128
218
|
*/
|
|
129
219
|
export function hasLiveSession(): boolean {
|
|
130
|
-
const
|
|
131
|
-
|
|
220
|
+
const c = ctx();
|
|
221
|
+
const session = c.sessions.getCurrentSession();
|
|
222
|
+
return !!c.sdk && !!session && c.sessions.isSessionValid(session.sessionId);
|
|
132
223
|
}
|
|
133
224
|
|
|
134
225
|
// =============================================================================
|
|
@@ -206,36 +297,37 @@ function realtimeIsHealthy(s: any): boolean {
|
|
|
206
297
|
* {@link reconnecting} so a parked socket isn't reconnected N times in parallel.
|
|
207
298
|
*/
|
|
208
299
|
async function tryRecoverSession(): Promise<boolean> {
|
|
209
|
-
|
|
210
|
-
if (!
|
|
211
|
-
|
|
212
|
-
|
|
300
|
+
const c = ctx();
|
|
301
|
+
if (!c.reconnectResolver) return false;
|
|
302
|
+
if (!c.reconnecting) {
|
|
303
|
+
const resolver = c.reconnectResolver;
|
|
304
|
+
c.reconnecting = (async () => {
|
|
213
305
|
const params = await resolver();
|
|
214
306
|
if (!params?.sandboxId) return; // nothing provisioned this session
|
|
215
307
|
// Close any half-dead socket on the outgoing SDK before replacing it, so a
|
|
216
308
|
// parked-then-rebuilt session doesn't leak an orphaned Ably connection.
|
|
217
309
|
try {
|
|
218
|
-
sdk?.sandbox?._ably?.close?.();
|
|
310
|
+
c.sdk?.sandbox?._ably?.close?.();
|
|
219
311
|
} catch {
|
|
220
312
|
/* best effort — reconnectSession installs a fresh SDK regardless */
|
|
221
313
|
}
|
|
222
314
|
await reconnectSession(params);
|
|
223
315
|
})().finally(() => {
|
|
224
|
-
reconnecting = null;
|
|
316
|
+
c.reconnecting = null;
|
|
225
317
|
});
|
|
226
318
|
}
|
|
227
319
|
try {
|
|
228
|
-
await reconnecting;
|
|
320
|
+
await c.reconnecting;
|
|
229
321
|
} catch (err) {
|
|
230
|
-
sdk = null;
|
|
322
|
+
c.sdk = null;
|
|
231
323
|
if (err instanceof NoActiveSessionError) throw err;
|
|
232
324
|
throw new NoActiveSessionError(
|
|
233
325
|
"SESSION_EXPIRED",
|
|
234
326
|
`Could not reconnect to the sandbox: ${err instanceof Error ? err.message : String(err)}. The sandbox has expired — call session_start again to create a new one.`
|
|
235
327
|
);
|
|
236
328
|
}
|
|
237
|
-
const session =
|
|
238
|
-
return !!sdk && !!session &&
|
|
329
|
+
const session = c.sessions.getCurrentSession();
|
|
330
|
+
return !!c.sdk && !!session && c.sessions.isSessionValid(session.sessionId);
|
|
239
331
|
}
|
|
240
332
|
|
|
241
333
|
/**
|
|
@@ -249,18 +341,19 @@ async function tryRecoverSession(): Promise<boolean> {
|
|
|
249
341
|
* to recover from.
|
|
250
342
|
*/
|
|
251
343
|
async function requireActiveSession(): Promise<void> {
|
|
252
|
-
const
|
|
344
|
+
const c = ctx();
|
|
345
|
+
const session = c.sessions.getCurrentSession();
|
|
253
346
|
|
|
254
347
|
// Recoverable conditions:
|
|
255
|
-
// -
|
|
348
|
+
// - context state wiped by a host recycle (`!sdk || !session`), or
|
|
256
349
|
// - the SDK exists but its realtime socket died during a park
|
|
257
350
|
// (`!realtimeIsHealthy`). We only treat a dead socket as recoverable when a
|
|
258
351
|
// recovery hook is registered — i.e. a durable host (eve) that actually
|
|
259
352
|
// parks. Long-lived hosts (stdio MCP, CLI) register no resolver, so a
|
|
260
353
|
// transient in-process Ably blip is left to Ably's own auto-reconnect rather
|
|
261
|
-
// than forcing a full
|
|
262
|
-
const socketDead = !!sdk && !!session && !!reconnectResolver && !realtimeIsHealthy(sdk);
|
|
263
|
-
const needsRecovery = !sdk || !session || socketDead;
|
|
354
|
+
// than forcing a full rebuild — preserving their prior behavior.
|
|
355
|
+
const socketDead = !!c.sdk && !!session && !!c.reconnectResolver && !realtimeIsHealthy(c.sdk);
|
|
356
|
+
const needsRecovery = !c.sdk || !session || socketDead;
|
|
264
357
|
if (needsRecovery) {
|
|
265
358
|
const recovered = await tryRecoverSession();
|
|
266
359
|
if (!recovered) {
|
|
@@ -268,14 +361,14 @@ async function requireActiveSession(): Promise<void> {
|
|
|
268
361
|
// If the socket was dead, don't send the action into the void — surface
|
|
269
362
|
// SESSION_EXPIRED so the agent re-provisions instead of hanging.
|
|
270
363
|
if (socketDead) {
|
|
271
|
-
sdk = null;
|
|
364
|
+
c.sdk = null;
|
|
272
365
|
throw new NoActiveSessionError(
|
|
273
366
|
"SESSION_EXPIRED",
|
|
274
367
|
"The sandbox connection was lost and could not be restored. Call session_start again to create a new sandbox session.",
|
|
275
368
|
session?.sessionId
|
|
276
369
|
);
|
|
277
370
|
}
|
|
278
|
-
// Otherwise the
|
|
371
|
+
// Otherwise the SDK was simply absent — the standard NO_SESSION.
|
|
279
372
|
throw new NoActiveSessionError(
|
|
280
373
|
"NO_SESSION",
|
|
281
374
|
"No active session. Call session_start first to create a sandbox before using any other tools."
|
|
@@ -283,9 +376,9 @@ async function requireActiveSession(): Promise<void> {
|
|
|
283
376
|
}
|
|
284
377
|
}
|
|
285
378
|
|
|
286
|
-
const current =
|
|
287
|
-
if (!current || !
|
|
288
|
-
sdk = null;
|
|
379
|
+
const current = c.sessions.getCurrentSession();
|
|
380
|
+
if (!current || !c.sessions.isSessionValid(current.sessionId)) {
|
|
381
|
+
c.sdk = null;
|
|
289
382
|
throw new NoActiveSessionError(
|
|
290
383
|
"SESSION_EXPIRED",
|
|
291
384
|
"Session has expired or timed out. Call session_start again to create a new sandbox session.",
|
|
@@ -294,13 +387,13 @@ async function requireActiveSession(): Promise<void> {
|
|
|
294
387
|
}
|
|
295
388
|
|
|
296
389
|
// Reset the keepAlive timer on each command so active use doesn't expire.
|
|
297
|
-
|
|
390
|
+
c.sessions.refreshSession(current.sessionId);
|
|
298
391
|
}
|
|
299
392
|
|
|
300
393
|
/** Capture a fresh full-screen screenshot as bare base64 (or null on failure). */
|
|
301
394
|
async function captureScreen(): Promise<string | null> {
|
|
302
395
|
try {
|
|
303
|
-
const b64 = await sdk.agent.system.captureScreenBase64(1, false, true);
|
|
396
|
+
const b64 = await ctx().sdk.agent.system.captureScreenBase64(1, false, true);
|
|
304
397
|
return b64 || null;
|
|
305
398
|
} catch {
|
|
306
399
|
return null;
|
|
@@ -326,6 +419,7 @@ export async function sessionStart(
|
|
|
326
419
|
resolved: { os: "linux" | "windows"; e2bTemplateId?: string; apiKey?: string },
|
|
327
420
|
hooks: SessionStartHooks = {}
|
|
328
421
|
): Promise<ActionResult> {
|
|
422
|
+
const c = ctx();
|
|
329
423
|
const progress = hooks.onProgress ?? (() => {});
|
|
330
424
|
|
|
331
425
|
// Validate required fields for specific provision types (unless reconnecting).
|
|
@@ -338,7 +432,7 @@ export async function sessionStart(
|
|
|
338
432
|
}
|
|
339
433
|
}
|
|
340
434
|
|
|
341
|
-
const newSession =
|
|
435
|
+
const newSession = c.sessions.createSession({
|
|
342
436
|
os: resolved.os,
|
|
343
437
|
keepAlive: params.keepAlive,
|
|
344
438
|
testFile: params.testFile,
|
|
@@ -366,7 +460,7 @@ export async function sessionStart(
|
|
|
366
460
|
}
|
|
367
461
|
|
|
368
462
|
const TestDriverSDK = (await loadTestDriverSdk()).default;
|
|
369
|
-
sdk = new TestDriverSDK(apiKey, {
|
|
463
|
+
c.sdk = new TestDriverSDK(apiKey, {
|
|
370
464
|
os: resolved.os,
|
|
371
465
|
logging: false,
|
|
372
466
|
apiRoot,
|
|
@@ -378,13 +472,13 @@ export async function sessionStart(
|
|
|
378
472
|
// Debug mode — attach to an existing sandbox, skip provisioning.
|
|
379
473
|
if (params.sandboxId) {
|
|
380
474
|
progress(`Connecting to existing sandbox ${params.sandboxId}...`);
|
|
381
|
-
await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
382
|
-
const instance = sdk.getInstance();
|
|
383
|
-
|
|
475
|
+
await c.sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
476
|
+
const instance = c.sdk.getInstance();
|
|
477
|
+
c.sessions.activateSession(newSession.sessionId, instance?.instanceId || params.sandboxId);
|
|
384
478
|
|
|
385
479
|
progress("Capturing screenshot...");
|
|
386
480
|
const shot = await captureScreen();
|
|
387
|
-
if (shot) lastScreenshotBase64 = shot;
|
|
481
|
+
if (shot) c.lastScreenshotBase64 = shot;
|
|
388
482
|
|
|
389
483
|
return {
|
|
390
484
|
ok: true,
|
|
@@ -396,10 +490,10 @@ export async function sessionStart(
|
|
|
396
490
|
}
|
|
397
491
|
|
|
398
492
|
progress(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
|
|
399
|
-
await sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
|
|
493
|
+
await c.sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
|
|
400
494
|
|
|
401
|
-
const instance = sdk.getInstance();
|
|
402
|
-
|
|
495
|
+
const instance = c.sdk.getInstance();
|
|
496
|
+
c.sessions.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
|
|
403
497
|
|
|
404
498
|
const provisionOptions = getProvisionOptions(params);
|
|
405
499
|
let provisionCmd = "";
|
|
@@ -407,30 +501,30 @@ export async function sessionStart(
|
|
|
407
501
|
progress(`Provisioning ${params.type}...`);
|
|
408
502
|
switch (params.type) {
|
|
409
503
|
case "chrome":
|
|
410
|
-
await sdk.provision.chrome(provisionOptions);
|
|
504
|
+
await c.sdk.provision.chrome(provisionOptions);
|
|
411
505
|
provisionCmd = "provision.chrome";
|
|
412
506
|
break;
|
|
413
507
|
case "chromeExtension":
|
|
414
|
-
await sdk.provision.chromeExtension(provisionOptions);
|
|
508
|
+
await c.sdk.provision.chromeExtension(provisionOptions);
|
|
415
509
|
provisionCmd = "provision.chromeExtension";
|
|
416
510
|
break;
|
|
417
511
|
case "vscode":
|
|
418
|
-
await sdk.provision.vscode(provisionOptions);
|
|
512
|
+
await c.sdk.provision.vscode(provisionOptions);
|
|
419
513
|
provisionCmd = "provision.vscode";
|
|
420
514
|
break;
|
|
421
515
|
case "installer":
|
|
422
|
-
await sdk.provision.installer(provisionOptions);
|
|
516
|
+
await c.sdk.provision.installer(provisionOptions);
|
|
423
517
|
provisionCmd = "provision.installer";
|
|
424
518
|
break;
|
|
425
519
|
case "electron":
|
|
426
|
-
await sdk.provision.electron(provisionOptions);
|
|
520
|
+
await c.sdk.provision.electron(provisionOptions);
|
|
427
521
|
provisionCmd = "provision.electron";
|
|
428
522
|
break;
|
|
429
523
|
}
|
|
430
524
|
|
|
431
525
|
progress("Capturing screenshot...");
|
|
432
526
|
const shot = await captureScreen();
|
|
433
|
-
if (shot) lastScreenshotBase64 = shot;
|
|
527
|
+
if (shot) c.lastScreenshotBase64 = shot;
|
|
434
528
|
|
|
435
529
|
const debuggerUrl = instance?.debuggerUrl || (instanceIp ? `http://${instanceIp}:9222` : null);
|
|
436
530
|
const connectionType = instanceIp ? `Self-hosted (${instanceIp})` : "Cloud";
|
|
@@ -492,17 +586,18 @@ export interface ReconnectParams {
|
|
|
492
586
|
* knows to call session_start again.
|
|
493
587
|
*/
|
|
494
588
|
export async function reconnectSession(params: ReconnectParams): Promise<string> {
|
|
589
|
+
const c = ctx();
|
|
495
590
|
const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
|
|
496
591
|
const previewMode = process.env.TD_PREVIEW || "ide";
|
|
497
592
|
|
|
498
|
-
const session =
|
|
593
|
+
const session = c.sessions.createSession({
|
|
499
594
|
os: params.os,
|
|
500
595
|
keepAlive: params.keepAlive,
|
|
501
596
|
testFile: params.testFile,
|
|
502
597
|
});
|
|
503
598
|
|
|
504
599
|
const TestDriverSDK = (await loadTestDriverSdk()).default;
|
|
505
|
-
sdk = new TestDriverSDK(params.apiKey, {
|
|
600
|
+
c.sdk = new TestDriverSDK(params.apiKey, {
|
|
506
601
|
os: params.os,
|
|
507
602
|
logging: false,
|
|
508
603
|
apiRoot,
|
|
@@ -511,9 +606,9 @@ export async function reconnectSession(params: ReconnectParams): Promise<string>
|
|
|
511
606
|
e2bTemplateId: params.e2bTemplateId,
|
|
512
607
|
});
|
|
513
608
|
|
|
514
|
-
await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
515
|
-
const instance = sdk.getInstance();
|
|
516
|
-
|
|
609
|
+
await c.sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
610
|
+
const instance = c.sdk.getInstance();
|
|
611
|
+
c.sessions.activateSession(session.sessionId, instance?.instanceId || params.sandboxId);
|
|
517
612
|
return session.sessionId;
|
|
518
613
|
}
|
|
519
614
|
|
|
@@ -530,9 +625,10 @@ export async function reconnectSession(params: ReconnectParams): Promise<string>
|
|
|
530
625
|
* them exactly as before.
|
|
531
626
|
*/
|
|
532
627
|
export async function ensureActiveSession(params?: ReconnectParams): Promise<boolean> {
|
|
533
|
-
const
|
|
628
|
+
const c = ctx();
|
|
629
|
+
const session = c.sessions.getCurrentSession();
|
|
534
630
|
// Already have a live SDK + valid session — nothing to do.
|
|
535
|
-
if (sdk && session &&
|
|
631
|
+
if (c.sdk && session && c.sessions.isSessionValid(session.sessionId)) {
|
|
536
632
|
return false;
|
|
537
633
|
}
|
|
538
634
|
// No durable handle to recover from — leave the (missing/expired) state as-is
|
|
@@ -548,7 +644,7 @@ export async function ensureActiveSession(params?: ReconnectParams): Promise<boo
|
|
|
548
644
|
// The sandbox is gone (server killed it after grace/disconnect cap, or the
|
|
549
645
|
// id is stale). Surface a SESSION_EXPIRED so the adapter's mapper tells the
|
|
550
646
|
// agent to call session_start again, instead of a raw connect error.
|
|
551
|
-
sdk = null;
|
|
647
|
+
c.sdk = null;
|
|
552
648
|
throw new NoActiveSessionError(
|
|
553
649
|
"SESSION_EXPIRED",
|
|
554
650
|
`Could not reconnect to sandbox ${params.sandboxId}: ${err instanceof Error ? err.message : String(err)}. The sandbox has expired — call session_start again to create a new one.`,
|
|
@@ -560,18 +656,19 @@ export async function ensureActiveSession(params?: ReconnectParams): Promise<boo
|
|
|
560
656
|
/** Disconnect the SDK (best-effort); used by adapters on cancel/teardown. */
|
|
561
657
|
export async function disconnect(): Promise<void> {
|
|
562
658
|
try {
|
|
563
|
-
await sdk?.disconnect?.();
|
|
659
|
+
await ctx().sdk?.disconnect?.();
|
|
564
660
|
} catch {
|
|
565
661
|
/* best effort */
|
|
566
662
|
}
|
|
567
663
|
}
|
|
568
664
|
|
|
569
665
|
export function sessionStatus(): ActionResult {
|
|
570
|
-
const
|
|
666
|
+
const c = ctx();
|
|
667
|
+
const session = c.sessions.getCurrentSession();
|
|
571
668
|
if (!session) {
|
|
572
669
|
return { ok: false, text: "No active session", data: { error: "No active session. Call session_start first." } };
|
|
573
670
|
}
|
|
574
|
-
const summary =
|
|
671
|
+
const summary = c.sessions.getSessionSummary(session.sessionId);
|
|
575
672
|
return {
|
|
576
673
|
ok: true,
|
|
577
674
|
text: `Session: ${session.sessionId}\nStatus: ${session.status}\nTime remaining: ${Math.round((summary?.timeRemaining || 0) / 1000)}s`,
|
|
@@ -580,10 +677,11 @@ export function sessionStatus(): ActionResult {
|
|
|
580
677
|
}
|
|
581
678
|
|
|
582
679
|
export function sessionExtend(additionalMs: number): ActionResult {
|
|
583
|
-
const
|
|
680
|
+
const c = ctx();
|
|
681
|
+
const session = c.sessions.getCurrentSession();
|
|
584
682
|
if (!session) return { ok: false, text: "No active session", data: { action: "session_extend" } };
|
|
585
|
-
|
|
586
|
-
const newExpiry =
|
|
683
|
+
c.sessions.extendSession(session.sessionId, additionalMs);
|
|
684
|
+
const newExpiry = c.sessions.getTimeRemaining(session.sessionId);
|
|
587
685
|
return {
|
|
588
686
|
ok: true,
|
|
589
687
|
text: `Session extended by ${additionalMs / 1000}s. New expiry: ${Math.round(newExpiry / 1000)}s`,
|
|
@@ -597,13 +695,14 @@ export function sessionExtend(additionalMs: number): ActionResult {
|
|
|
597
695
|
|
|
598
696
|
export async function find(description: string, timeout?: number): Promise<ActionResult> {
|
|
599
697
|
await requireActiveSession();
|
|
600
|
-
const
|
|
698
|
+
const c = ctx();
|
|
699
|
+
const element = await c.sdk.find(description, timeout ? { timeout } : undefined);
|
|
601
700
|
const found = element.found();
|
|
602
701
|
const coords = element.getCoordinates();
|
|
603
702
|
|
|
604
703
|
const ref = `el-${Date.now()}`;
|
|
605
704
|
if (found && coords) {
|
|
606
|
-
elementRefs.set(ref, {
|
|
705
|
+
c.elementRefs.set(ref, {
|
|
607
706
|
element,
|
|
608
707
|
description,
|
|
609
708
|
coords: { x: coords.x, y: coords.y, centerX: coords.centerX, centerY: coords.centerY },
|
|
@@ -638,7 +737,8 @@ export async function find(description: string, timeout?: number): Promise<Actio
|
|
|
638
737
|
|
|
639
738
|
export async function findAll(description: string, timeout?: number): Promise<ActionResult> {
|
|
640
739
|
await requireActiveSession();
|
|
641
|
-
const
|
|
740
|
+
const c = ctx();
|
|
741
|
+
const elements = await c.sdk.findAll(description, timeout ? { timeout } : undefined);
|
|
642
742
|
const count = elements.length;
|
|
643
743
|
|
|
644
744
|
const refs: string[] = [];
|
|
@@ -648,7 +748,7 @@ export async function findAll(description: string, timeout?: number): Promise<Ac
|
|
|
648
748
|
const coords = el.getCoordinates();
|
|
649
749
|
if (!coords) continue;
|
|
650
750
|
const ref = `el-${Date.now()}-${i}`;
|
|
651
|
-
elementRefs.set(ref, { element: el, description: `${description} [${i}]`, coords });
|
|
751
|
+
c.elementRefs.set(ref, { element: el, description: `${description} [${i}]`, coords });
|
|
652
752
|
refs.push(ref);
|
|
653
753
|
elementInfos.push({ ref, x: coords.x, y: coords.y, centerX: coords.centerX, centerY: coords.centerY, confidence: el.confidence });
|
|
654
754
|
}
|
|
@@ -685,7 +785,8 @@ async function actOnRef(
|
|
|
685
785
|
action: "click" | "hover"
|
|
686
786
|
): Promise<ActionResult> {
|
|
687
787
|
await requireActiveSession();
|
|
688
|
-
const
|
|
788
|
+
const c = ctx();
|
|
789
|
+
const stored = c.elementRefs.get(ref);
|
|
689
790
|
if (!stored) {
|
|
690
791
|
return { ok: false, text: `Element reference "${ref}" not found. Use 'find' first to locate the element.`, data: { error: "Element reference not found" } };
|
|
691
792
|
}
|
|
@@ -697,7 +798,7 @@ async function actOnRef(
|
|
|
697
798
|
else if (verb === "hover") await element.hover();
|
|
698
799
|
|
|
699
800
|
const shot = await captureScreen();
|
|
700
|
-
if (shot) lastScreenshotBase64 = shot;
|
|
801
|
+
if (shot) c.lastScreenshotBase64 = shot;
|
|
701
802
|
const data = leanResponse(element._response);
|
|
702
803
|
|
|
703
804
|
if (action === "hover") {
|
|
@@ -727,7 +828,8 @@ export function hover(ref: string): Promise<ActionResult> {
|
|
|
727
828
|
|
|
728
829
|
export async function findAndClick(description: string, action: "click" | "double-click" | "right-click" = "click"): Promise<ActionResult> {
|
|
729
830
|
await requireActiveSession();
|
|
730
|
-
const
|
|
831
|
+
const c = ctx();
|
|
832
|
+
const element = await c.sdk.find(description);
|
|
731
833
|
const found = element.found();
|
|
732
834
|
|
|
733
835
|
if (!found) {
|
|
@@ -748,7 +850,7 @@ export async function findAndClick(description: string, action: "click" | "doubl
|
|
|
748
850
|
|
|
749
851
|
const coords = element.getCoordinates();
|
|
750
852
|
const ref = `el-${Date.now()}`;
|
|
751
|
-
if (coords) elementRefs.set(ref, { element, description, coords });
|
|
853
|
+
if (coords) c.elementRefs.set(ref, { element, description, coords });
|
|
752
854
|
|
|
753
855
|
if (action === "click") await element.click();
|
|
754
856
|
else if (action === "double-click") await element.doubleClick();
|
|
@@ -781,7 +883,7 @@ export async function findAndClick(description: string, action: "click" | "doubl
|
|
|
781
883
|
|
|
782
884
|
export async function type(text: string, secret = false, delay?: number): Promise<ActionResult> {
|
|
783
885
|
await requireActiveSession();
|
|
784
|
-
await sdk.type(text, { secret, delay });
|
|
886
|
+
await ctx().sdk.type(text, { secret, delay });
|
|
785
887
|
return {
|
|
786
888
|
ok: true,
|
|
787
889
|
text: `Typed: ${secret ? "[secret text]" : `"${text}"`}`,
|
|
@@ -792,7 +894,7 @@ export async function type(text: string, secret = false, delay?: number): Promis
|
|
|
792
894
|
|
|
793
895
|
export async function pressKeys(keys: string[]): Promise<ActionResult> {
|
|
794
896
|
await requireActiveSession();
|
|
795
|
-
await sdk.pressKeys(keys);
|
|
897
|
+
await ctx().sdk.pressKeys(keys);
|
|
796
898
|
return {
|
|
797
899
|
ok: true,
|
|
798
900
|
text: `Pressed keys: ${keys.join(" + ")}`,
|
|
@@ -803,7 +905,7 @@ export async function pressKeys(keys: string[]): Promise<ActionResult> {
|
|
|
803
905
|
|
|
804
906
|
export async function scroll(direction: "up" | "down" | "left" | "right" = "down", amount?: number): Promise<ActionResult> {
|
|
805
907
|
await requireActiveSession();
|
|
806
|
-
await sdk.scroll(direction, amount ? { amount } : undefined);
|
|
908
|
+
await ctx().sdk.scroll(direction, amount ? { amount } : undefined);
|
|
807
909
|
return {
|
|
808
910
|
ok: true,
|
|
809
911
|
text: `Scrolled ${direction}${amount ? ` by ${amount}px` : ""}`,
|
|
@@ -814,7 +916,7 @@ export async function scroll(direction: "up" | "down" | "left" | "right" = "down
|
|
|
814
916
|
|
|
815
917
|
export async function focusApplication(name: string): Promise<ActionResult> {
|
|
816
918
|
await requireActiveSession();
|
|
817
|
-
await sdk.focusApplication(name);
|
|
919
|
+
await ctx().sdk.focusApplication(name);
|
|
818
920
|
return {
|
|
819
921
|
ok: true,
|
|
820
922
|
text: `Focused application: "${name}"`,
|
|
@@ -825,7 +927,7 @@ export async function focusApplication(name: string): Promise<ActionResult> {
|
|
|
825
927
|
|
|
826
928
|
export async function wait(timeout: number): Promise<ActionResult> {
|
|
827
929
|
await requireActiveSession();
|
|
828
|
-
await sdk.wait(timeout);
|
|
930
|
+
await ctx().sdk.wait(timeout);
|
|
829
931
|
return {
|
|
830
932
|
ok: true,
|
|
831
933
|
text: `Waited for ${timeout}ms`,
|
|
@@ -836,7 +938,7 @@ export async function wait(timeout: number): Promise<ActionResult> {
|
|
|
836
938
|
|
|
837
939
|
export async function exec(language: "sh" | "pwsh", code: string, timeout = 30000): Promise<ActionResult> {
|
|
838
940
|
await requireActiveSession();
|
|
839
|
-
const output = await sdk.exec(language, code, timeout);
|
|
941
|
+
const output = await ctx().sdk.exec(language, code, timeout);
|
|
840
942
|
return {
|
|
841
943
|
ok: true,
|
|
842
944
|
text: `Executed ${language} code:\n${output || "(no output)"}`,
|
|
@@ -851,7 +953,7 @@ export async function exec(language: "sh" | "pwsh", code: string, timeout = 3000
|
|
|
851
953
|
|
|
852
954
|
export async function assert(assertion: string): Promise<ActionResult> {
|
|
853
955
|
await requireActiveSession();
|
|
854
|
-
const result = await sdk.assert(assertion);
|
|
956
|
+
const result = await ctx().sdk.assert(assertion);
|
|
855
957
|
return {
|
|
856
958
|
ok: result,
|
|
857
959
|
text: result ? `✓ Assertion passed: "${assertion}"` : `✗ Assertion failed: "${assertion}"`,
|
|
@@ -867,14 +969,15 @@ export async function assert(assertion: string): Promise<ActionResult> {
|
|
|
867
969
|
*/
|
|
868
970
|
export async function check(task: string, referenceImage?: string): Promise<ActionResult> {
|
|
869
971
|
await requireActiveSession();
|
|
870
|
-
const
|
|
871
|
-
const
|
|
872
|
-
|
|
972
|
+
const c = ctx();
|
|
973
|
+
const currentScreenshot = await c.sdk.agent.system.captureScreenBase64(1, false, true);
|
|
974
|
+
const beforeScreenshot = referenceImage || c.lastScreenshotBase64 || currentScreenshot;
|
|
975
|
+
c.lastScreenshotBase64 = currentScreenshot;
|
|
873
976
|
|
|
874
|
-
const mousePosition = await sdk.agent.system.getMousePosition();
|
|
875
|
-
const activeWindow = await sdk.agent.system.activeWin();
|
|
977
|
+
const mousePosition = await c.sdk.agent.system.getMousePosition();
|
|
978
|
+
const activeWindow = await c.sdk.agent.system.activeWin();
|
|
876
979
|
|
|
877
|
-
const response = await sdk.agent.sdk.req("check", {
|
|
980
|
+
const response = await c.sdk.agent.sdk.req("check", {
|
|
878
981
|
tasks: [task],
|
|
879
982
|
images: [beforeScreenshot, currentScreenshot],
|
|
880
983
|
mousePosition,
|
|
@@ -899,8 +1002,9 @@ export async function check(task: string, referenceImage?: string): Promise<Acti
|
|
|
899
1002
|
/** Capture a screenshot for the user (cursor visible). */
|
|
900
1003
|
export async function screenshot(): Promise<ActionResult> {
|
|
901
1004
|
await requireActiveSession();
|
|
902
|
-
const
|
|
903
|
-
|
|
1005
|
+
const c = ctx();
|
|
1006
|
+
const shot = await c.sdk.agent.system.captureScreenBase64(1, false, true);
|
|
1007
|
+
if (shot) c.lastScreenshotBase64 = shot;
|
|
904
1008
|
return {
|
|
905
1009
|
ok: !!shot,
|
|
906
1010
|
text: shot ? "Captured screenshot" : "Failed to capture screenshot",
|