@stigmer/runner 3.4.0 → 3.5.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/dist/.build-fingerprint +1 -1
- package/dist/activities/attach-session.d.ts +34 -0
- package/dist/activities/attach-session.js +95 -0
- package/dist/activities/attach-session.js.map +1 -0
- package/dist/activities/discover-mcp-server.d.ts +9 -0
- package/dist/activities/discover-mcp-server.js +6 -1
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/connect-backfill.d.ts +2 -1
- package/dist/activities/execute-cursor/connect-backfill.js +2 -2
- package/dist/activities/execute-cursor/connect-backfill.js.map +1 -1
- package/dist/activities/execute-cursor/fetch-interceptor.js +31 -0
- package/dist/activities/execute-cursor/fetch-interceptor.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +70 -3
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/mcp-resolver.d.ts +7 -1
- package/dist/activities/execute-cursor/mcp-resolver.js +15 -2
- package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/sdk-warmup.d.ts +44 -0
- package/dist/activities/execute-cursor/sdk-warmup.js +61 -0
- package/dist/activities/execute-cursor/sdk-warmup.js.map +1 -0
- package/dist/activities/execute-deep-agent/setup.js +48 -5
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +9 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js +19 -5
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +13 -1
- package/dist/client/stigmer-client.js +40 -5
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/client/token-claims.d.ts +9 -6
- package/dist/client/token-claims.js +22 -6
- package/dist/client/token-claims.js.map +1 -1
- package/dist/main.d.ts +8 -1
- package/dist/main.js +122 -2
- package/dist/main.js.map +1 -1
- package/dist/middleware/index.d.ts +0 -1
- package/dist/middleware/index.js +0 -1
- package/dist/middleware/index.js.map +1 -1
- package/dist/otel-metrics.d.ts +10 -1
- package/dist/otel-metrics.js +22 -1
- package/dist/otel-metrics.js.map +1 -1
- package/dist/pool-member.d.ts +61 -0
- package/dist/pool-member.js +51 -0
- package/dist/pool-member.js.map +1 -0
- package/dist/runner-manager.d.ts +7 -0
- package/dist/runner-manager.js +39 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +15 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/cold-start-timing.d.ts +89 -0
- package/dist/shared/cold-start-timing.js +166 -0
- package/dist/shared/cold-start-timing.js.map +1 -0
- package/dist/shared/connect-backfill.d.ts +2 -1
- package/dist/shared/connect-backfill.js +4 -2
- package/dist/shared/connect-backfill.js.map +1 -1
- package/dist/shared/mcp-manager.d.ts +5 -15
- package/dist/shared/mcp-manager.js +5 -32
- package/dist/shared/mcp-manager.js.map +1 -1
- package/dist/shared/mcp-resolver.d.ts +15 -5
- package/dist/shared/mcp-resolver.js +23 -6
- package/dist/shared/mcp-resolver.js.map +1 -1
- package/dist/shared/mcp-transport-guard.d.ts +53 -0
- package/dist/shared/mcp-transport-guard.js +70 -0
- package/dist/shared/mcp-transport-guard.js.map +1 -0
- package/dist/shared/model-registry.d.ts +4 -6
- package/dist/shared/model-registry.js +4 -6
- package/dist/shared/model-registry.js.map +1 -1
- package/dist/shared/tool-kind.js +6 -0
- package/dist/shared/tool-kind.js.map +1 -1
- package/dist/tools/index.d.ts +27 -0
- package/dist/tools/index.js +27 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/think-tool.js.map +1 -0
- package/dist/tools/url-guard.d.ts +64 -0
- package/dist/tools/url-guard.js +211 -0
- package/dist/tools/url-guard.js.map +1 -0
- package/dist/tools/web-fetch-tool.d.ts +53 -0
- package/dist/tools/web-fetch-tool.js +216 -0
- package/dist/tools/web-fetch-tool.js.map +1 -0
- package/package.json +4 -2
- package/src/__tests__/otel-metrics.test.ts +6 -0
- package/src/__tests__/pool-member.test.ts +76 -0
- package/src/activities/__tests__/attach-session.test.ts +133 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +34 -11
- package/src/activities/attach-session.ts +112 -0
- package/src/activities/discover-mcp-server.ts +19 -1
- package/src/activities/execute-cursor/__tests__/fetch-interceptor.test.ts +77 -0
- package/src/activities/execute-cursor/__tests__/sdk-warmup.test.ts +45 -0
- package/src/activities/execute-cursor/connect-backfill.ts +3 -0
- package/src/activities/execute-cursor/fetch-interceptor.ts +39 -0
- package/src/activities/execute-cursor/index.ts +72 -3
- package/src/activities/execute-cursor/mcp-resolver.ts +21 -2
- package/src/activities/execute-cursor/sdk-warmup.ts +68 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +24 -0
- package/src/activities/execute-deep-agent/setup.ts +48 -7
- package/src/activities/execute-deep-agent/subagent-transformer.ts +27 -2
- package/src/client/__tests__/stigmer-client.test.ts +77 -2
- package/src/client/__tests__/token-claims.test.ts +15 -1
- package/src/client/stigmer-client.ts +51 -6
- package/src/client/token-claims.ts +29 -6
- package/src/main.ts +139 -2
- package/src/middleware/index.ts +0 -1
- package/src/otel-metrics.ts +25 -1
- package/src/pool-member.ts +99 -0
- package/src/runner-manager.ts +52 -0
- package/src/runner.ts +15 -0
- package/src/shared/__tests__/cold-start-timing.test.ts +271 -0
- package/src/shared/__tests__/connect-backfill.test.ts +11 -11
- package/src/shared/__tests__/mcp-manager.test.ts +2 -73
- package/src/shared/__tests__/mcp-resolver.test.ts +108 -0
- package/src/shared/__tests__/mcp-transport-guard.test.ts +71 -0
- package/src/shared/__tests__/model-registry.test.ts +6 -4
- package/src/shared/cold-start-timing.ts +197 -0
- package/src/shared/connect-backfill.ts +5 -1
- package/src/shared/mcp-manager.ts +4 -41
- package/src/shared/mcp-resolver.ts +29 -6
- package/src/shared/mcp-transport-guard.ts +81 -0
- package/src/shared/model-registry.ts +4 -6
- package/src/shared/tool-kind.ts +6 -0
- package/src/tools/__tests__/url-guard.test.ts +137 -0
- package/src/tools/__tests__/web-fetch-tool.test.ts +193 -0
- package/src/tools/index.ts +28 -0
- package/src/tools/url-guard.ts +232 -0
- package/src/tools/web-fetch-tool.ts +275 -0
- package/dist/middleware/think-tool.js.map +0 -1
- /package/dist/{middleware → tools}/think-tool.d.ts +0 -0
- /package/dist/{middleware → tools}/think-tool.js +0 -0
- /package/src/{middleware → tools}/__tests__/think-tool.test.ts +0 -0
- /package/src/{middleware → tools}/think-tool.ts +0 -0
package/src/runner-manager.ts
CHANGED
|
@@ -25,6 +25,10 @@ import {
|
|
|
25
25
|
import type { PayloadCodec } from "@temporalio/common";
|
|
26
26
|
import type { Config } from "./config.js";
|
|
27
27
|
import { DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS, DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
|
|
28
|
+
// Boot marks mirror the static runner's segment names where the work is the
|
|
29
|
+
// same. Inert unless a mode emits the boot timeline (pool mode does; the
|
|
30
|
+
// desktop manager never calls emitRunnerBootTiming, so marks cost nothing).
|
|
31
|
+
import { markBoot } from "./shared/cold-start-timing.js";
|
|
28
32
|
import type { WorkerActivities } from "./worker.js";
|
|
29
33
|
import { resolveWorkflowSource, OTEL_WORKFLOW_INTERCEPTOR_MODULE } from "./workflow-source.js";
|
|
30
34
|
import { resolveRunnerBootstrap, refreshRunnerAccessToken } from "./bootstrap.js";
|
|
@@ -43,6 +47,10 @@ import {
|
|
|
43
47
|
|
|
44
48
|
const SESSION_QUEUE_PREFIX = "session:";
|
|
45
49
|
const WFEXEC_QUEUE_PREFIX = "wfexec:";
|
|
50
|
+
// Warm-pool control queue — polled by exactly one pool member, carrying only
|
|
51
|
+
// the ProbePoolMember/AttachSession claim activities (never session work).
|
|
52
|
+
// Mirrors SessionDispatchService's convention: lowercase kind, colon, id.
|
|
53
|
+
const POOL_CONTROL_QUEUE_PREFIX = "sandbox:";
|
|
46
54
|
|
|
47
55
|
/**
|
|
48
56
|
* Module-level registry of shutdown signals per task queue.
|
|
@@ -142,6 +150,14 @@ export interface StigmerRunnerManager {
|
|
|
142
150
|
/** List currently active workflow execution IDs. */
|
|
143
151
|
activeWorkflowExecutions(): string[];
|
|
144
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Start the warm-pool control worker — a Worker polling sandbox:{memberId}
|
|
155
|
+
* for the claim activities (ProbePoolMember, AttachSession). Called once by
|
|
156
|
+
* the pool boot path; the attach flow then adds the claimed session via
|
|
157
|
+
* {@link addSession} like any other host.
|
|
158
|
+
*/
|
|
159
|
+
addPoolControl(memberId: string): Promise<void>;
|
|
160
|
+
|
|
145
161
|
/**
|
|
146
162
|
* Push a refreshed *control-plane* auth token (the host's durable credential,
|
|
147
163
|
* e.g. the desktop's Auth0 token) to all activity clients.
|
|
@@ -250,6 +266,7 @@ export async function createStigmerRunnerManager(
|
|
|
250
266
|
// facade unpatched (otherwise BiDi streams would silently 401). No-op when
|
|
251
267
|
// the interceptor is unconfigured (no proxy/token).
|
|
252
268
|
await assertHttp2ConnectPatched();
|
|
269
|
+
markBoot("interceptors_installed");
|
|
253
270
|
|
|
254
271
|
// The token coordinator owns the proxy credential (x-stigmer-auth) and its
|
|
255
272
|
// refresh lifecycle. It writes ONLY its sinks (the interceptors plus the gRPC
|
|
@@ -287,6 +304,7 @@ export async function createStigmerRunnerManager(
|
|
|
287
304
|
temporalAddress: bootstrap.temporalAddress,
|
|
288
305
|
temporalNamespace: bootstrap.temporalNamespace,
|
|
289
306
|
};
|
|
307
|
+
markBoot("bootstrap_resolved");
|
|
290
308
|
|
|
291
309
|
// Adopt the minted proxy token (cloud): it diverges from the control-plane
|
|
292
310
|
// token from here on, and the coordinator refreshes it before expiry.
|
|
@@ -313,11 +331,13 @@ export async function createStigmerRunnerManager(
|
|
|
313
331
|
setExecutionContextRef(getExecutionContext());
|
|
314
332
|
|
|
315
333
|
const activities = await createAllActivities(config);
|
|
334
|
+
markBoot("activities_imported");
|
|
316
335
|
const payloadCodec = await createPayloadCodec(config);
|
|
317
336
|
|
|
318
337
|
const connection = await NativeConnection.connect({
|
|
319
338
|
address: config.temporalAddress,
|
|
320
339
|
});
|
|
340
|
+
markBoot("connection_opened");
|
|
321
341
|
|
|
322
342
|
const interceptorConfig = await buildInterceptorConfig();
|
|
323
343
|
|
|
@@ -336,10 +356,13 @@ export async function createStigmerRunnerManager(
|
|
|
336
356
|
});
|
|
337
357
|
console.log("[runner-manager] Workflow code bundled successfully");
|
|
338
358
|
}
|
|
359
|
+
markBoot("workflow_bundle_ready");
|
|
339
360
|
|
|
340
361
|
const sessions = new Map<string, ManagedSession>();
|
|
341
362
|
const workflowExecutions = new Map<string, ManagedSession>();
|
|
342
363
|
const shutdownSignals = new Map<string, AbortController>();
|
|
364
|
+
// At most one per process: a pool member IS its control worker's identity.
|
|
365
|
+
let poolControl: { taskQueue: string; managed: ManagedSession } | null = null;
|
|
343
366
|
let shuttingDown = false;
|
|
344
367
|
|
|
345
368
|
async function createWorkerOnQueue(taskQueue: string): Promise<ManagedSession> {
|
|
@@ -502,6 +525,23 @@ export async function createStigmerRunnerManager(
|
|
|
502
525
|
return Array.from(workflowExecutions.keys());
|
|
503
526
|
},
|
|
504
527
|
|
|
528
|
+
async addPoolControl(memberId: string): Promise<void> {
|
|
529
|
+
if (shuttingDown) {
|
|
530
|
+
throw new Error("RunnerManager is shutting down");
|
|
531
|
+
}
|
|
532
|
+
if (poolControl) {
|
|
533
|
+
throw new Error(
|
|
534
|
+
`Pool control worker already polling ${poolControl.taskQueue} — ` +
|
|
535
|
+
"a process is exactly one pool member",
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
const taskQueue = POOL_CONTROL_QUEUE_PREFIX + memberId;
|
|
539
|
+
poolControl = { taskQueue, managed: await createWorkerOnQueue(taskQueue) };
|
|
540
|
+
console.log(
|
|
541
|
+
`[runner-manager] Added pool control worker (queue=${taskQueue})`,
|
|
542
|
+
);
|
|
543
|
+
},
|
|
544
|
+
|
|
505
545
|
updateToken(token: string | null): void {
|
|
506
546
|
// Writes the control-plane credential. The coordinator decides whether the
|
|
507
547
|
// proxy credential follows it: only when no token has been minted (so the
|
|
@@ -541,10 +581,19 @@ export async function createStigmerRunnerManager(
|
|
|
541
581
|
},
|
|
542
582
|
),
|
|
543
583
|
];
|
|
584
|
+
if (poolControl) {
|
|
585
|
+
const control = poolControl;
|
|
586
|
+
shutdownPromises.push((async () => {
|
|
587
|
+
control.managed.worker.shutdown();
|
|
588
|
+
await control.managed.runPromise;
|
|
589
|
+
console.log(`[runner-manager] Pool control worker (${control.taskQueue}) stopped`);
|
|
590
|
+
})());
|
|
591
|
+
}
|
|
544
592
|
|
|
545
593
|
await Promise.all(shutdownPromises);
|
|
546
594
|
sessions.clear();
|
|
547
595
|
workflowExecutions.clear();
|
|
596
|
+
poolControl = null;
|
|
548
597
|
connection.close();
|
|
549
598
|
console.log("[runner-manager] All workers stopped, connection closed");
|
|
550
599
|
},
|
|
@@ -629,6 +678,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
629
678
|
{ createHydrateWorkflowActivities },
|
|
630
679
|
{ createWorkflowEventActivities },
|
|
631
680
|
{ createPromoteTaskOutputActivities },
|
|
681
|
+
{ createAttachSessionActivities },
|
|
632
682
|
] = await Promise.all([
|
|
633
683
|
import("./activities/execute-cursor/index.js"),
|
|
634
684
|
import("./activities/execute-deep-agent/index.js"),
|
|
@@ -646,6 +696,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
646
696
|
import("./activities/hydrate-workflow-execution.js"),
|
|
647
697
|
import("./activities/workflow-event-activities.js"),
|
|
648
698
|
import("./activities/promote-task-output.js"),
|
|
699
|
+
import("./activities/attach-session.js"),
|
|
649
700
|
]);
|
|
650
701
|
|
|
651
702
|
return {
|
|
@@ -665,6 +716,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
665
716
|
...createHydrateWorkflowActivities(config),
|
|
666
717
|
...createWorkflowEventActivities(),
|
|
667
718
|
...createPromoteTaskOutputActivities(),
|
|
719
|
+
...createAttachSessionActivities(config),
|
|
668
720
|
};
|
|
669
721
|
}
|
|
670
722
|
|
package/src/runner.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { Config } from "./config.js";
|
|
|
20
20
|
import { DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS, DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
|
|
21
21
|
import type { WorkerActivities } from "./worker.js";
|
|
22
22
|
import { resolveRunnerBootstrap } from "./bootstrap.js";
|
|
23
|
+
import { markBoot, emitRunnerBootTiming } from "./shared/cold-start-timing.js";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Configuration for creating a Stigmer runner.
|
|
@@ -179,6 +180,7 @@ export async function createStigmerRunner(
|
|
|
179
180
|
// facade unpatched (otherwise BiDi streams would silently 401). No-op when
|
|
180
181
|
// the interceptor is unconfigured (no proxy/token).
|
|
181
182
|
await assertHttp2ConnectPatched();
|
|
183
|
+
markBoot("interceptors_installed");
|
|
182
184
|
|
|
183
185
|
// Resolve Temporal coordinates after the http2 patch is in place (discovery
|
|
184
186
|
// dials the control plane through connect-node). Explicit address wins;
|
|
@@ -201,6 +203,7 @@ export async function createStigmerRunner(
|
|
|
201
203
|
temporalAddress: coordinates.temporalAddress,
|
|
202
204
|
temporalNamespace: coordinates.temporalNamespace,
|
|
203
205
|
};
|
|
206
|
+
markBoot("bootstrap_resolved");
|
|
204
207
|
|
|
205
208
|
const { setExecutionContextRef } = await import(
|
|
206
209
|
"./activities/execute-cursor/rejection-capture.js"
|
|
@@ -208,6 +211,7 @@ export async function createStigmerRunner(
|
|
|
208
211
|
setExecutionContextRef(getExecutionContext());
|
|
209
212
|
|
|
210
213
|
const activities = await createAllActivities(config);
|
|
214
|
+
markBoot("activities_imported");
|
|
211
215
|
|
|
212
216
|
console.log(
|
|
213
217
|
`[runner] Registered activities: ${Object.keys(activities).join(", ")}`,
|
|
@@ -222,10 +226,18 @@ export async function createStigmerRunner(
|
|
|
222
226
|
|
|
223
227
|
const { startWorker } = await import("./worker.js");
|
|
224
228
|
const worker = await startWorker({ config, activities, payloadCodec });
|
|
229
|
+
markBoot("worker_created");
|
|
225
230
|
|
|
226
231
|
return {
|
|
227
232
|
async start() {
|
|
228
233
|
console.log("Worker ready, polling for tasks...");
|
|
234
|
+
// The boot timeline ends here: the worker is about to poll, so the
|
|
235
|
+
// sandbox can now receive its first activity. The final mark closes the
|
|
236
|
+
// worker_created → about-to-poll span — without it, total_ms would stop
|
|
237
|
+
// at worker_created and understate the boot time the warm-pool go/no-go
|
|
238
|
+
// decision reads.
|
|
239
|
+
markBoot("worker_polling");
|
|
240
|
+
emitRunnerBootTiming({ task_queue: config.taskQueue, mode: config.mode });
|
|
229
241
|
await worker.run();
|
|
230
242
|
console.log("Worker stopped");
|
|
231
243
|
},
|
|
@@ -320,6 +332,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
320
332
|
{ createHydrateWorkflowActivities },
|
|
321
333
|
{ createWorkflowEventActivities },
|
|
322
334
|
{ createPromoteTaskOutputActivities },
|
|
335
|
+
{ createAttachSessionActivities },
|
|
323
336
|
] = await Promise.all([
|
|
324
337
|
import("./activities/execute-cursor/index.js"),
|
|
325
338
|
import("./activities/execute-deep-agent/index.js"),
|
|
@@ -337,6 +350,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
337
350
|
import("./activities/hydrate-workflow-execution.js"),
|
|
338
351
|
import("./activities/workflow-event-activities.js"),
|
|
339
352
|
import("./activities/promote-task-output.js"),
|
|
353
|
+
import("./activities/attach-session.js"),
|
|
340
354
|
]);
|
|
341
355
|
|
|
342
356
|
return {
|
|
@@ -356,6 +370,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
356
370
|
...createHydrateWorkflowActivities(config),
|
|
357
371
|
...createWorkflowEventActivities(),
|
|
358
372
|
...createPromoteTaskOutputActivities(),
|
|
373
|
+
...createAttachSessionActivities(config),
|
|
359
374
|
};
|
|
360
375
|
}
|
|
361
376
|
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the cold-start timing instrumentation.
|
|
3
|
+
*
|
|
4
|
+
* The warm-agent-surfaces baseline report derives its waterfall from these
|
|
5
|
+
* emissions, so the invariants pinned here are load-bearing for a spend
|
|
6
|
+
* decision: segments must partition the timeline (total == sum, no gaps),
|
|
7
|
+
* the emitted line must keep its stable shape (`stigmer_timing` selector,
|
|
8
|
+
* context spread, `segments` with per-segment `start_ms`/`duration_ms`),
|
|
9
|
+
* and the process-boot timeline must emit exactly once — a worker restart
|
|
10
|
+
* inside one process must never produce a bogus second timeline.
|
|
11
|
+
*
|
|
12
|
+
* The boot singleton is module-level state, so those tests re-import the
|
|
13
|
+
* module fresh via `vi.resetModules()` to get an unlatched instance.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
import { TimingRecorder, emitTimingLog, recordTimingMetric } from "../cold-start-timing.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Captures histogram records made through the instrument registry, so the
|
|
21
|
+
* metric-mirror tests can assert on instrument choice and attribute
|
|
22
|
+
* whitelisting. `failGetInstruments` simulates a broken registry to pin the
|
|
23
|
+
* "telemetry never breaks the runner" posture.
|
|
24
|
+
*/
|
|
25
|
+
const metricsMock = vi.hoisted(() => ({
|
|
26
|
+
recorded: [] as Array<{ instrument: string; value: number; attrs?: Record<string, string> }>,
|
|
27
|
+
failGetInstruments: false,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock("../../otel-metrics.js", () => ({
|
|
31
|
+
getInstruments: async () => {
|
|
32
|
+
if (metricsMock.failGetInstruments) {
|
|
33
|
+
throw new Error("registry unavailable");
|
|
34
|
+
}
|
|
35
|
+
const capture = (instrument: string) => ({
|
|
36
|
+
record: (value: number, attrs?: Record<string, string>) => {
|
|
37
|
+
metricsMock.recorded.push({ instrument, value, attrs });
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
runnerBootDuration: capture("runner_boot"),
|
|
42
|
+
executionSetupDuration: capture("execution_setup"),
|
|
43
|
+
poolAttachDuration: capture("pool_attach"),
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
/** Parse the single JSON line the mocked console.log captured. */
|
|
49
|
+
function loggedJson(spy: ReturnType<typeof vi.spyOn>): Record<string, unknown> {
|
|
50
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
51
|
+
return JSON.parse(spy.mock.calls[0]![0] as string);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
vi.restoreAllMocks();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("TimingRecorder", () => {
|
|
59
|
+
it("partitions the timeline: total equals the sum of segment durations", () => {
|
|
60
|
+
const recorder = new TimingRecorder();
|
|
61
|
+
recorder.mark("first");
|
|
62
|
+
recorder.mark("second");
|
|
63
|
+
recorder.mark("third");
|
|
64
|
+
|
|
65
|
+
const segments = recorder.snapshot();
|
|
66
|
+
expect(segments.map((s) => s.name)).toEqual(["first", "second", "third"]);
|
|
67
|
+
|
|
68
|
+
const sum = segments.reduce((acc, s) => acc + s.durationMs, 0);
|
|
69
|
+
// Each value is independently rounded to 0.1ms, so allow rounding drift.
|
|
70
|
+
expect(Math.abs(recorder.totalMs() - sum)).toBeLessThanOrEqual(
|
|
71
|
+
0.05 * (segments.length + 1),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("leaves no gaps: each segment starts where the previous one ended", () => {
|
|
76
|
+
const recorder = new TimingRecorder();
|
|
77
|
+
recorder.mark("a");
|
|
78
|
+
recorder.mark("b");
|
|
79
|
+
recorder.mark("c");
|
|
80
|
+
|
|
81
|
+
const segments = recorder.snapshot();
|
|
82
|
+
for (let i = 1; i < segments.length; i++) {
|
|
83
|
+
const previousEnd = segments[i - 1]!.startMs + segments[i - 1]!.durationMs;
|
|
84
|
+
expect(Math.abs(segments[i]!.startMs - previousEnd)).toBeLessThanOrEqual(0.11);
|
|
85
|
+
}
|
|
86
|
+
expect(segments[0]!.startMs).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("measures offsets from an explicit origin (the process-boot case)", () => {
|
|
90
|
+
const recorder = new TimingRecorder(0);
|
|
91
|
+
recorder.mark("node_start");
|
|
92
|
+
// Origin 0 means the first segment's duration covers everything since
|
|
93
|
+
// process start — it must be a real, positive elapsed time.
|
|
94
|
+
expect(recorder.snapshot()[0]!.durationMs).toBeGreaterThan(0);
|
|
95
|
+
expect(recorder.totalMs()).toBeGreaterThan(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("reports zero total before any mark", () => {
|
|
99
|
+
expect(new TimingRecorder().totalMs()).toBe(0);
|
|
100
|
+
expect(new TimingRecorder().snapshot()).toHaveLength(0);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("emitTimingLog", () => {
|
|
105
|
+
it("emits one line with the stable shape the baseline report parses", () => {
|
|
106
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
107
|
+
const recorder = new TimingRecorder();
|
|
108
|
+
recorder.mark("only_segment");
|
|
109
|
+
|
|
110
|
+
emitTimingLog("execution_setup", {
|
|
111
|
+
execution_id: "exe_123",
|
|
112
|
+
session_id: "ses_456",
|
|
113
|
+
harness: "cursor",
|
|
114
|
+
skipped: undefined,
|
|
115
|
+
nullable: null,
|
|
116
|
+
}, recorder);
|
|
117
|
+
|
|
118
|
+
const line = loggedJson(spy);
|
|
119
|
+
expect(line.stigmer_timing).toBe("execution_setup");
|
|
120
|
+
expect(line.execution_id).toBe("exe_123");
|
|
121
|
+
expect(line.session_id).toBe("ses_456");
|
|
122
|
+
// undefined context values are dropped by JSON.stringify; null survives.
|
|
123
|
+
expect("skipped" in line).toBe(false);
|
|
124
|
+
expect(line.nullable).toBeNull();
|
|
125
|
+
expect(line.total_ms).toBeTypeOf("number");
|
|
126
|
+
|
|
127
|
+
const segments = line.segments as Array<Record<string, unknown>>;
|
|
128
|
+
expect(segments).toHaveLength(1);
|
|
129
|
+
expect(segments[0]!.name).toBe("only_segment");
|
|
130
|
+
expect(segments[0]!.start_ms).toBeTypeOf("number");
|
|
131
|
+
expect(segments[0]!.duration_ms).toBeTypeOf("number");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("never throws, even when the payload cannot be serialized", () => {
|
|
135
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
136
|
+
const cyclic: Record<string, unknown> = {};
|
|
137
|
+
cyclic.self = cyclic;
|
|
138
|
+
|
|
139
|
+
expect(() =>
|
|
140
|
+
// The context type forbids objects, but telemetry must survive a caller
|
|
141
|
+
// getting it wrong at runtime — that is the module's stated posture.
|
|
142
|
+
emitTimingLog("evt", cyclic as never, new TimingRecorder()),
|
|
143
|
+
).not.toThrow();
|
|
144
|
+
expect(spy).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("timing metrics mirror", () => {
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
metricsMock.recorded.length = 0;
|
|
151
|
+
metricsMock.failGetInstruments = false;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("mirrors each mapped timeline onto its histogram with whitelisted attrs only", async () => {
|
|
155
|
+
recordTimingMetric("runner_boot", {
|
|
156
|
+
mode: "cloud",
|
|
157
|
+
pool_member_id: "pm_secret",
|
|
158
|
+
task_queue: "sandbox:pm_secret",
|
|
159
|
+
}, 6200);
|
|
160
|
+
recordTimingMetric("execution_setup", {
|
|
161
|
+
harness: "cursor",
|
|
162
|
+
execution_id: "exe_123",
|
|
163
|
+
session_id: "ses_456",
|
|
164
|
+
}, 3000);
|
|
165
|
+
recordTimingMetric("pool_attach", {
|
|
166
|
+
pool_member_id: "pm_secret",
|
|
167
|
+
session_id: "ses_456",
|
|
168
|
+
}, 400);
|
|
169
|
+
|
|
170
|
+
await vi.waitFor(() => expect(metricsMock.recorded).toHaveLength(3));
|
|
171
|
+
|
|
172
|
+
const byInstrument = Object.fromEntries(
|
|
173
|
+
metricsMock.recorded.map((r) => [r.instrument, r]),
|
|
174
|
+
);
|
|
175
|
+
expect(byInstrument.runner_boot!.value).toBe(6200);
|
|
176
|
+
// The whitelist is the cardinality guard: ids must never become attrs.
|
|
177
|
+
expect(byInstrument.runner_boot!.attrs).toEqual({ mode: "cloud" });
|
|
178
|
+
expect(byInstrument.execution_setup!.value).toBe(3000);
|
|
179
|
+
expect(byInstrument.execution_setup!.attrs).toEqual({ harness: "cursor" });
|
|
180
|
+
expect(byInstrument.pool_attach!.value).toBe(400);
|
|
181
|
+
expect(byInstrument.pool_attach!.attrs).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("emitTimingLog drives the mirror, so call sites need no metric code", async () => {
|
|
185
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
186
|
+
const recorder = new TimingRecorder();
|
|
187
|
+
recorder.mark("only_segment");
|
|
188
|
+
|
|
189
|
+
emitTimingLog("execution_setup", { harness: "native", execution_id: "exe_1" }, recorder);
|
|
190
|
+
|
|
191
|
+
await vi.waitFor(() => expect(metricsMock.recorded).toHaveLength(1));
|
|
192
|
+
expect(metricsMock.recorded[0]!.instrument).toBe("execution_setup");
|
|
193
|
+
expect(metricsMock.recorded[0]!.attrs).toEqual({ harness: "native" });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("records nothing for unmapped events (control-plane timelines)", async () => {
|
|
197
|
+
recordTimingMetric("execution_create", { org: "acme" }, 500);
|
|
198
|
+
recordTimingMetric("pool_attach", {}, 1);
|
|
199
|
+
|
|
200
|
+
// The mapped event landing proves the unmapped one was skipped, without
|
|
201
|
+
// relying on an arbitrary sleep.
|
|
202
|
+
await vi.waitFor(() => expect(metricsMock.recorded).toHaveLength(1));
|
|
203
|
+
expect(metricsMock.recorded[0]!.instrument).toBe("pool_attach");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("survives a broken instrument registry without throwing or logging", async () => {
|
|
207
|
+
metricsMock.failGetInstruments = true;
|
|
208
|
+
|
|
209
|
+
expect(() => recordTimingMetric("runner_boot", { mode: "cloud" }, 100)).not.toThrow();
|
|
210
|
+
// Give the floating promise a tick to reject and be swallowed.
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
212
|
+
expect(metricsMock.recorded).toHaveLength(0);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("boot timeline singleton", () => {
|
|
217
|
+
// Each test re-imports the module for a fresh, unlatched singleton.
|
|
218
|
+
async function freshModule() {
|
|
219
|
+
vi.resetModules();
|
|
220
|
+
return import("../cold-start-timing.js");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
it("emits runner_boot exactly once; later emits are no-ops", async () => {
|
|
224
|
+
const mod = await freshModule();
|
|
225
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
226
|
+
|
|
227
|
+
mod.markBoot("config_loaded");
|
|
228
|
+
mod.emitRunnerBootTiming({ task_queue: "session:ses_1", mode: "cloud" });
|
|
229
|
+
mod.emitRunnerBootTiming({ task_queue: "session:ses_1", mode: "cloud" });
|
|
230
|
+
|
|
231
|
+
const line = loggedJson(spy);
|
|
232
|
+
expect(line.stigmer_timing).toBe("runner_boot");
|
|
233
|
+
expect(line.task_queue).toBe("session:ses_1");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("ignores marks after the boot timeline has emitted", async () => {
|
|
237
|
+
const mod = await freshModule();
|
|
238
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
239
|
+
|
|
240
|
+
mod.markBoot("config_loaded");
|
|
241
|
+
mod.markBoot("worker_polling");
|
|
242
|
+
mod.emitRunnerBootTiming({ mode: "cloud" });
|
|
243
|
+
mod.markBoot("late_mark_must_not_appear");
|
|
244
|
+
|
|
245
|
+
const line = loggedJson(spy);
|
|
246
|
+
const names = (line.segments as Array<{ name: string }>).map((s) => s.name);
|
|
247
|
+
expect(names).toEqual(["config_loaded", "worker_polling"]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("closes the timeline at the final mark: total covers every named span", async () => {
|
|
251
|
+
const mod = await freshModule();
|
|
252
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
253
|
+
|
|
254
|
+
mod.markBoot("worker_created");
|
|
255
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
256
|
+
mod.markBoot("worker_polling");
|
|
257
|
+
mod.emitRunnerBootTiming({ mode: "cloud" });
|
|
258
|
+
|
|
259
|
+
const line = loggedJson(spy);
|
|
260
|
+
const segments = line.segments as Array<{ name: string; duration_ms: number }>;
|
|
261
|
+
const last = segments[segments.length - 1]!;
|
|
262
|
+
// The regression this pins: without a final mark before emit, the
|
|
263
|
+
// worker_created → polling span would vanish from total_ms entirely.
|
|
264
|
+
expect(last.name).toBe("worker_polling");
|
|
265
|
+
expect(last.duration_ms).toBeGreaterThan(0);
|
|
266
|
+
const sum = segments.reduce((acc, s) => acc + s.duration_ms, 0);
|
|
267
|
+
expect(Math.abs((line.total_ms as number) - sum)).toBeLessThanOrEqual(
|
|
268
|
+
0.05 * (segments.length + 1),
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -124,7 +124,7 @@ describe("backfillMcpServersIfNeeded", () => {
|
|
|
124
124
|
const client = makeMockClient();
|
|
125
125
|
|
|
126
126
|
const result = await backfillMcpServersIfNeeded(
|
|
127
|
-
client, servers, [], {}, "org",
|
|
127
|
+
client, servers, [], {}, "org", "stdio-allowed",
|
|
128
128
|
);
|
|
129
129
|
|
|
130
130
|
expect(result).toBe(servers);
|
|
@@ -137,7 +137,7 @@ describe("backfillMcpServersIfNeeded", () => {
|
|
|
137
137
|
const client = makeMockClient();
|
|
138
138
|
|
|
139
139
|
const result = await backfillMcpServersIfNeeded(
|
|
140
|
-
client, servers, [], {}, "org",
|
|
140
|
+
client, servers, [], {}, "org", "stdio-allowed",
|
|
141
141
|
);
|
|
142
142
|
|
|
143
143
|
expect(result).toBe(servers);
|
|
@@ -155,7 +155,7 @@ describe("backfillMcpServersIfNeeded", () => {
|
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
const result = await backfillMcpServersIfNeeded(
|
|
158
|
-
client, servers, usages, {}, "org",
|
|
158
|
+
client, servers, usages, {}, "org", "stdio-allowed",
|
|
159
159
|
);
|
|
160
160
|
|
|
161
161
|
expect(client.getMcpServerByReference).toHaveBeenCalledOnce();
|
|
@@ -181,7 +181,7 @@ describe("backfillMcpServersIfNeeded", () => {
|
|
|
181
181
|
});
|
|
182
182
|
|
|
183
183
|
const result = await backfillMcpServersIfNeeded(
|
|
184
|
-
client, servers, usages, {}, "org",
|
|
184
|
+
client, servers, usages, {}, "org", "stdio-allowed",
|
|
185
185
|
);
|
|
186
186
|
|
|
187
187
|
expect(client.connectMcpServer).toHaveBeenCalledOnce();
|
|
@@ -206,7 +206,7 @@ describe("backfillMcpServersIfNeeded", () => {
|
|
|
206
206
|
});
|
|
207
207
|
|
|
208
208
|
await backfillMcpServersIfNeeded(
|
|
209
|
-
client, servers, usages, mergedEnv, "org",
|
|
209
|
+
client, servers, usages, mergedEnv, "org", "stdio-allowed",
|
|
210
210
|
);
|
|
211
211
|
|
|
212
212
|
expect(client.connectMcpServer).toHaveBeenCalledWith(
|
|
@@ -224,7 +224,7 @@ describe("backfillMcpServersIfNeeded", () => {
|
|
|
224
224
|
});
|
|
225
225
|
|
|
226
226
|
const result = await backfillMcpServersIfNeeded(
|
|
227
|
-
client, servers, usages, {}, "org",
|
|
227
|
+
client, servers, usages, {}, "org", "stdio-allowed",
|
|
228
228
|
);
|
|
229
229
|
|
|
230
230
|
expect(result).toBe(servers);
|
|
@@ -242,7 +242,7 @@ describe("backfillMcpServersIfNeeded", () => {
|
|
|
242
242
|
});
|
|
243
243
|
|
|
244
244
|
const result = await backfillMcpServersIfNeeded(
|
|
245
|
-
client, servers, usages, {}, "org",
|
|
245
|
+
client, servers, usages, {}, "org", "stdio-allowed",
|
|
246
246
|
);
|
|
247
247
|
|
|
248
248
|
expect(result).toBe(servers);
|
|
@@ -261,7 +261,7 @@ describe("backfillMcpServersIfNeeded", () => {
|
|
|
261
261
|
});
|
|
262
262
|
|
|
263
263
|
await backfillMcpServersIfNeeded(
|
|
264
|
-
client, servers, usages, {}, "org", onHeartbeat,
|
|
264
|
+
client, servers, usages, {}, "org", "stdio-allowed", onHeartbeat,
|
|
265
265
|
);
|
|
266
266
|
|
|
267
267
|
expect(onHeartbeat).toHaveBeenCalledTimes(2);
|
|
@@ -274,7 +274,7 @@ describe("backfillMcpServersIfNeeded", () => {
|
|
|
274
274
|
const client = makeMockClient();
|
|
275
275
|
|
|
276
276
|
const result = await backfillMcpServersIfNeeded(
|
|
277
|
-
client, servers, [], {}, "org",
|
|
277
|
+
client, servers, [], {}, "org", "stdio-allowed",
|
|
278
278
|
);
|
|
279
279
|
|
|
280
280
|
expect(client.getMcpServerByReference).not.toHaveBeenCalled();
|
|
@@ -294,7 +294,7 @@ describe("backfillMcpServersIfNeeded", () => {
|
|
|
294
294
|
});
|
|
295
295
|
|
|
296
296
|
const result = await backfillMcpServersIfNeeded(
|
|
297
|
-
client, servers, usages, {}, "org",
|
|
297
|
+
client, servers, usages, {}, "org", "stdio-allowed",
|
|
298
298
|
);
|
|
299
299
|
|
|
300
300
|
expect(client.connectMcpServer).not.toHaveBeenCalled();
|
|
@@ -337,7 +337,7 @@ describe("backfillMcpServersIfNeeded", () => {
|
|
|
337
337
|
});
|
|
338
338
|
|
|
339
339
|
const result = await backfillMcpServersIfNeeded(
|
|
340
|
-
client, servers, usages, {}, "org",
|
|
340
|
+
client, servers, usages, {}, "org", "stdio-allowed",
|
|
341
341
|
);
|
|
342
342
|
|
|
343
343
|
expect(callCount).toBe(2);
|
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import {
|
|
3
|
-
isCloudCompatibleCommand,
|
|
4
|
-
warnCloudIncompatibleServers,
|
|
5
|
-
toMcpClientConfig,
|
|
6
|
-
} from "../mcp-manager.js";
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { toMcpClientConfig } from "../mcp-manager.js";
|
|
7
3
|
import type { ResolvedMcpServer } from "../mcp-resolver.js";
|
|
8
4
|
|
|
9
5
|
function makeServer(overrides: Partial<ResolvedMcpServer>): ResolvedMcpServer {
|
|
@@ -17,73 +13,6 @@ function makeServer(overrides: Partial<ResolvedMcpServer>): ResolvedMcpServer {
|
|
|
17
13
|
};
|
|
18
14
|
}
|
|
19
15
|
|
|
20
|
-
describe("isCloudCompatibleCommand", () => {
|
|
21
|
-
it.each(["npx", "node", "uvx", "python", "python3"])(
|
|
22
|
-
"returns true for '%s'",
|
|
23
|
-
(cmd) => {
|
|
24
|
-
expect(isCloudCompatibleCommand(cmd)).toBe(true);
|
|
25
|
-
},
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
it.each(["/usr/local/bin/npx", "/home/user/.local/bin/uvx"])(
|
|
29
|
-
"returns true for full paths to known commands: '%s'",
|
|
30
|
-
(cmd) => {
|
|
31
|
-
expect(isCloudCompatibleCommand(cmd)).toBe(true);
|
|
32
|
-
},
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
it.each(["go", "my-custom-binary", "/usr/bin/mcp-server", "docker"])(
|
|
36
|
-
"returns false for '%s'",
|
|
37
|
-
(cmd) => {
|
|
38
|
-
expect(isCloudCompatibleCommand(cmd)).toBe(false);
|
|
39
|
-
},
|
|
40
|
-
);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe("warnCloudIncompatibleServers", () => {
|
|
44
|
-
it("does nothing when not in cloud mode", () => {
|
|
45
|
-
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
46
|
-
warnCloudIncompatibleServers(
|
|
47
|
-
[makeServer({ command: "go", connectionType: "stdio" })],
|
|
48
|
-
false,
|
|
49
|
-
);
|
|
50
|
-
expect(spy).not.toHaveBeenCalled();
|
|
51
|
-
spy.mockRestore();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("warns for non-installable stdio commands in cloud mode", () => {
|
|
55
|
-
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
56
|
-
warnCloudIncompatibleServers(
|
|
57
|
-
[makeServer({ slug: "custom", command: "my-binary", connectionType: "stdio" })],
|
|
58
|
-
true,
|
|
59
|
-
);
|
|
60
|
-
expect(spy).toHaveBeenCalledOnce();
|
|
61
|
-
expect(spy.mock.calls[0][0]).toContain("my-binary");
|
|
62
|
-
expect(spy.mock.calls[0][0]).toContain("custom");
|
|
63
|
-
spy.mockRestore();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("does not warn for npx commands in cloud mode", () => {
|
|
67
|
-
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
68
|
-
warnCloudIncompatibleServers(
|
|
69
|
-
[makeServer({ command: "npx", connectionType: "stdio" })],
|
|
70
|
-
true,
|
|
71
|
-
);
|
|
72
|
-
expect(spy).not.toHaveBeenCalled();
|
|
73
|
-
spy.mockRestore();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("does not warn for HTTP servers in cloud mode", () => {
|
|
77
|
-
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
78
|
-
warnCloudIncompatibleServers(
|
|
79
|
-
[makeServer({ connectionType: "http", url: "https://mcp.example.com" })],
|
|
80
|
-
true,
|
|
81
|
-
);
|
|
82
|
-
expect(spy).not.toHaveBeenCalled();
|
|
83
|
-
spy.mockRestore();
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
16
|
describe("toMcpClientConfig", () => {
|
|
88
17
|
it("maps stdio servers to the client config format", () => {
|
|
89
18
|
const servers = [
|