@vitest-agent/mcp 5.4.0 → 6.0.1

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.
@@ -1,21 +1,19 @@
1
1
  import { McpSession } from "../session.js";
2
- import { createRequire } from "node:module";
3
2
  import { ToolOutputSchema } from "@effected/mcp";
4
- import { Data, Effect, Layer, Option, Path, Result, Schema, Semaphore } from "effect";
5
- import { DataReader, DataStore } from "@vitest-agent/engine";
3
+ import { Context, Data, Effect, FileSystem, Layer, Option, Path, Result, Schema } from "effect";
6
4
  import { Tool } from "effect/ai";
7
- import { AgentReport, SuggestedActionRecord, buildAgentReport, buildConsoleLeaks, coerceErrorField, collectConsoleLeakEntries, formatScopedCoverageNote, readStrayOutput } from "@vitest-agent/sdk";
8
- import { AsyncLocalStorage } from "node:async_hooks";
9
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
5
+ import { DataReader, DataStore } from "@vitest-agent/engine";
6
+ import { AgentReport, RunDiagnostics, SuggestedActionRecord, VITEST_CONFIG_FILES, buildAgentReport, buildConsoleLeaks, coerceErrorField, collectConsoleLeakEntries, formatScopedCoverageNote, readStrayOutput, suggestedActionsFromRunDiagnostics } from "@vitest-agent/sdk";
7
+ import { Exclusive, ProcessScope } from "@effected/engine";
10
8
  import { stat } from "node:fs/promises";
11
- import { tmpdir } from "node:os";
12
- import { join, resolve } from "node:path";
13
- import { Writable } from "node:stream";
14
- import { fileURLToPath, pathToFileURL } from "node:url";
9
+ import { isAbsolute, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
15
11
  import * as NodeChildProcessSpawner from "@effect/platform-node/NodeChildProcessSpawner";
16
12
  import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
17
13
  import * as NodePath from "@effect/platform-node/NodePath";
18
- import { Git } from "@effected/git";
14
+ import { ModuleResolution } from "@effected/commands/node-sync";
15
+ import { Git, TreeIdentity } from "@effected/git";
16
+ import { StdioCapture } from "@effected/mcp/node";
19
17
  import { Walker } from "@effected/walker";
20
18
 
21
19
  // src/tools/run-tests.ts
@@ -44,7 +42,8 @@ const RunTestsOk = Schema.Struct({
44
42
  classifications: Schema.Record(Schema.String, Schema.String).annotate({ description: "Per-test classification labels: stable, new-failure, persistent, flaky, recovered." }),
45
43
  discoveryLastScannedAt: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "ISO timestamp of the most recent real disk scan performed by discoverProjects() in this process (issue #100). `null`/absent means discovery has not scanned disk in this process yet (e.g. a config that doesn't call AgentPlugin.discover()). A stale-looking test count is self-explaining when compared against this value." }),
46
44
  scopedNote: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "Set (issue #160) when this call was scoped to a subset of the project's test files (files/project/tags filter supplied). Vitest enforces coverage.thresholds against the whole-project denominator regardless of how many files ran, so a scoped run's threshold check is unreliable; this note explains why no coverage verdict should be trusted for this call. `null`/absent means the run was unscoped." }),
47
- suggestedActions: Schema.optionalKey(Schema.Array(SuggestedActionRecord)).annotate({ description: "Run-level suggested actions the plugin raised at config time (issue #544): one entry per coverage-config diagnostic the plugin's ConfigValidation reported, such as a coverageTargets metric with no matching coverage.thresholds floor (severity warn). The same entries print on the plugin's stderr for a human and in the rendered report's Actions trailer. Absent when the project's config validated clean or does not load the plugin." })
45
+ suggestedActions: Schema.optionalKey(Schema.Array(SuggestedActionRecord)).annotate({ description: "Run-level suggested actions the plugin raised at config time (issue #544): one entry per coverage-config diagnostic the plugin's ConfigValidation reported, such as a coverageTargets metric with no matching coverage.thresholds floor (severity warn). The same entries print on the plugin's stderr for a human and in the rendered report's Actions trailer. Absent when the project's config validated clean or does not load the plugin. Includes the actions derived from `runDiagnostics`, after the config ones." }),
46
+ runDiagnostics: Schema.optionalKey(RunDiagnostics).annotate({ description: "Run-level diagnostics the plugin reporter published for this run (issue #544): Vitest performance hints, post-run timing (persist, coverage scan, total) and whether results were persisted. Their suggested actions are merged into `suggestedActions`. Absent when the project does not load the plugin." })
48
47
  }).annotate({ identifier: "RunTestsOk" });
49
48
  const RunTestsTimeout = Schema.Struct({
50
49
  kind: Schema.Literal("timeout"),
@@ -142,6 +141,20 @@ function readConfigSuggestedActions(vitest) {
142
141
  const decoded = Schema.decodeUnknownResult(Schema.Array(SuggestedActionRecord))(value);
143
142
  return Result.isSuccess(decoded) ? decoded.success : void 0;
144
143
  }
144
+ const RUN_DIAGNOSTICS_SYMBOL = Symbol.for("vitest-agent:run-diagnostics");
145
+ /**
146
+ * Reads the run diagnostics the plugin published on a Vitest instance.
147
+ * `undefined` when none were published (no plugin, or a clean run) or the
148
+ * slot holds a foreign shape; the value is decoded against `RunDiagnostics`.
149
+ *
150
+ * @internal
151
+ */
152
+ function readRunDiagnostics(vitest) {
153
+ const value = vitest[RUN_DIAGNOSTICS_SYMBOL];
154
+ if (value === void 0 || value === null) return void 0;
155
+ const decoded = Schema.decodeUnknownResult(RunDiagnostics)(value);
156
+ return Result.isSuccess(decoded) ? decoded.success : void 0;
157
+ }
145
158
  /**
146
159
  * Per-invocation coverage temp directory. Vitest's v8 provider `rm -rf`s the
147
160
  * shared `coverage/` reports directory at run start (`clean: true` default),
@@ -154,60 +167,39 @@ function readConfigSuggestedActions(vitest) {
154
167
  * coverage from SQLite (`CoverageAnalyzer` consumes the in-memory map via
155
168
  * `onCoverage`), not from disk artifacts.
156
169
  *
157
- * @internal exported for tests
170
+ * Removed when the run's scope closes. The removal ignores a failure (a
171
+ * directory already gone, a tmpdir reaper racing it): core's
172
+ * `makeTempDirectoryScoped` dies on one, which would turn a finished run into
173
+ * a defect.
158
174
  */
159
- const makeCoverageDirOverride = () => {
160
- const dir = mkdtempSync(join(tmpdir(), "vitest-agent-cov-"));
161
- return {
162
- dir,
163
- coverage: { reportsDirectory: dir }
164
- };
165
- };
175
+ const coverageTempDirectory = Effect.flatMap(FileSystem.FileSystem, (fs) => Effect.acquireRelease(fs.makeTempDirectory({ prefix: "vitest-agent-cov-" }), (dir) => fs.remove(dir, {
176
+ recursive: true,
177
+ force: true
178
+ }).pipe(Effect.ignore)));
166
179
  /**
167
- * Typed wrapper for a rejected `vitest.start(...)` promise (issue #320).
168
- * Keeps the run's Effect error channel a tagged union so the timeout
169
- * branch can be recovered by tag instead of by string sentinel.
170
- */
171
- var VitestStartFailure = class extends Data.TaggedError("VitestStartFailure") {};
172
- const stdoutSinkStorage = new AsyncLocalStorage();
173
- const stderrSinkStorage = new AsyncLocalStorage();
174
- let _stdioPatched = false;
175
- let _originalStdoutWrite;
176
- let _originalStderrWrite;
177
- function ensureStdioPatched() {
178
- if (_stdioPatched) return;
179
- _stdioPatched = true;
180
- _originalStdoutWrite = process.stdout.write;
181
- _originalStderrWrite = process.stderr.write;
182
- process.stdout.write = function patchedStdoutWrite(...args) {
183
- const sink = stdoutSinkStorage.getStore();
184
- if (sink) return sink.write.apply(sink, args);
185
- return _originalStdoutWrite.apply(this, args);
186
- };
187
- process.stderr.write = function patchedStderrWrite(...args) {
188
- const sink = stderrSinkStorage.getStore();
189
- if (sink) return sink.write.apply(sink, args);
190
- return _originalStderrWrite.apply(this, args);
191
- };
192
- }
180
+ * Typed wrapper for a rejected step of the run (loading `vitest/node`,
181
+ * `createVitest`, `vitest.start`, the coverage temp dir). Keeps the run's
182
+ * error channel a tagged union so the timeout branches are recovered by tag
183
+ * (issue #320), and carries the raw cause for the `{ kind: "error" }` message.
184
+ */
185
+ var RunStepFailure = class extends Data.TaggedError("RunStepFailure") {};
193
186
  /**
194
- * Run `fn` with `process.stdout.write` and `process.stderr.write`
195
- * diverted to `stream.write` for code executing inside the call's
196
- * async context. Code in other async contexts (concurrent tool
197
- * procedure handlers, the MCP stdio transport) sees the original
198
- * writes unchanged.
199
- *
200
- * Vitest's own stdout/stderr redirect options only cover Vitest-internal
201
- * logging. User-registered reporters that call `console.log` directly
202
- * bypass them; this helper captures those writes into the supplied
203
- * sink so they don't corrupt the JSON-RPC protocol stream.
187
+ * The stdio capture `run_tests` diverts the nested Vitest run's
188
+ * `process.stdout` / `process.stderr` writes through, so a user reporter's
189
+ * `console.log` never reaches the JSON-RPC wire while every other tool
190
+ * handler (and the transport) keeps writing normally. `main.ts` installs it
191
+ * once at boot and provides it; the default installs it on first use for an
192
+ * embedder or a test that never ran `main.ts` (installing is idempotent per
193
+ * stream).
204
194
  *
205
195
  * @internal
206
196
  */
207
- async function withStdioCaptured(stream, fn) {
208
- ensureStdioPatched();
209
- return stdoutSinkStorage.run(stream, () => stderrSinkStorage.run(stream, fn));
210
- }
197
+ const RunTestsStdio = Context.Reference("@vitest-agent/mcp/RunTestsStdio", { defaultValue: () => StdioCapture.install(process) });
198
+ /** Both streams discarded for the run's own async context. */
199
+ const DISCARD_STDIO = {
200
+ stdout: StdioCapture.discard,
201
+ stderr: StdioCapture.discard
202
+ };
211
203
  function sanitizeTestArgs(args) {
212
204
  const result = [];
213
205
  for (const arg of args) {
@@ -227,15 +219,6 @@ function sanitizeTestArgs(args) {
227
219
  * as a silent pass.
228
220
  */
229
221
  const gitCommonDir = (dir) => Effect.flatMap(Git, (git) => git.commonDir(dir)).pipe(Effect.option, Effect.map(Option.getOrNull), Effect.catchDefect(() => Effect.succeed(null)));
230
- const VITEST_CONFIG_PREFIXES = ["vitest.config.", "vite.config."];
231
- const VITEST_CONFIG_EXTENSIONS = [
232
- "ts",
233
- "mts",
234
- "cts",
235
- "js",
236
- "mjs",
237
- "cjs"
238
- ];
239
222
  /**
240
223
  * The one walk both anchoring helpers share: step UP from `startDir`
241
224
  * until a vitest/vite config file is found, returning its absolute path.
@@ -256,7 +239,7 @@ const walkUpToConfigFile = (startDir) => Effect.gen(function* () {
256
239
  const start = path.resolve(startDir);
257
240
  const git = yield* Git;
258
241
  const dirs = yield* Walker.ascendWithin(start, yield* Effect.option(git.repoRoot(start)));
259
- const found = yield* Walker.findUpward(dirs, (dir) => VITEST_CONFIG_PREFIXES.flatMap((prefix) => VITEST_CONFIG_EXTENSIONS.map((ext) => path.join(dir, `${prefix}${ext}`))));
242
+ const found = yield* Walker.findUpward(dirs, (dir) => VITEST_CONFIG_FILES.map((name) => path.join(dir, name)));
260
243
  return Option.getOrNull(found);
261
244
  }).pipe(Effect.catchDefect(() => Effect.succeed(null)));
262
245
  /**
@@ -333,6 +316,46 @@ async function validateProjectRoot(projectRoot, ctxCwd) {
333
316
  };
334
317
  }
335
318
  /**
319
+ * The tree identity for `dir` (`Git.treeIdentity`: its physical toplevel and
320
+ * the shared common dir, from one `git rev-parse`), or `None` when git cannot
321
+ * answer (not a repository, a bare repository, git failure). Git answers
322
+ * physical paths, so identities are only ever compared with each other.
323
+ */
324
+ const treeIdentityOf = (dir) => Effect.flatMap(Git, (git) => git.treeIdentity(dir)).pipe(Effect.orElseSucceed(() => Option.none()), Effect.catchDefect(() => Effect.succeed(Option.none())));
325
+ /**
326
+ * Issue #275: refuse a call whose hook-injected `_callerCwd` sits in a
327
+ * DIFFERENT working tree of the same repository than the root the run would
328
+ * use. Anything indeterminate (absent, empty, relative, nonexistent, not a
329
+ * directory, outside git, a different repository) proceeds: the server
330
+ * cannot tell, and refusing on absence would break every non-plugin
331
+ * consumer.
332
+ *
333
+ * @internal exported for tests
334
+ */
335
+ async function checkCallerWorktree(callerCwd, resolvedRoot) {
336
+ if (callerCwd === void 0 || callerCwd === "" || !isAbsolute(callerCwd)) return {
337
+ ok: true,
338
+ root: resolvedRoot
339
+ };
340
+ if (!await stat(callerCwd).then((s) => s.isDirectory(), () => false)) return {
341
+ ok: true,
342
+ root: resolvedRoot
343
+ };
344
+ const [caller, root] = await runOnNodePlatform(Effect.all([treeIdentityOf(callerCwd), treeIdentityOf(resolvedRoot)], { concurrency: 2 }));
345
+ if (Option.isNone(caller) || Option.isNone(root)) return {
346
+ ok: true,
347
+ root: resolvedRoot
348
+ };
349
+ if (TreeIdentity.relation(caller.value, root.value) !== "sibling-worktree") return {
350
+ ok: true,
351
+ root: resolvedRoot
352
+ };
353
+ return {
354
+ ok: false,
355
+ message: `This call came from the working tree "${caller.value.toplevel}" but the Vitest root resolved to "${root.value.toplevel}", a different working tree of the same repository, so the run would report results for the wrong tree. Pass projectRoot (the absolute path of your own working tree, "${caller.value.toplevel}") to run there.`
356
+ };
357
+ }
358
+ /**
336
359
  * Issue #303: resolve `vitest/node` anchored at the run's project root
337
360
  * instead of the bare `"vitest/node"` specifier, which resolves relative to
338
361
  * `@vitest-agent/mcp`'s OWN install location. `vitest` is a peerDependency
@@ -348,17 +371,16 @@ async function validateProjectRoot(projectRoot, ctxCwd) {
348
371
  * for '<file>' is not found. Did you call 'SnapshotClient.setup()'?" while
349
372
  * every non-snapshot assertion still passes.
350
373
  *
351
- * `createRequire` needs a file path (not a bare directory) to anchor
352
- * resolution, hence the synthetic, never-created `__vitest-agent-resolver__.js`
353
- * filename joined onto `root`. vitest's package.json `exports` map for
354
- * `./node` carries a `default` condition (`./dist/node.js`) and vitest ships
355
- * `"type": "module"`, so `require.resolve("vitest/node")` resolves correctly
356
- * even though vitest itself is ESM — the result is then converted to a
357
- * `file://` URL, which is what dynamic `import()` needs.
374
+ * Resolution is `ModuleResolution.resolve` from `@effected/commands`,
375
+ * anchored at `root` (a directory works as-is). vitest's package.json
376
+ * `exports` map for `./node` carries a `default` condition, so CommonJS
377
+ * resolution finds it even though vitest itself is ESM; the answer is the
378
+ * real path (Node's resolver follows symlinks), which is the spelling that
379
+ * identifies the module instance.
358
380
  *
359
381
  * Falls back to the bare `"vitest/node"` specifier when root-anchored
360
- * resolution throws (e.g. a project root with no local vitest install) so
361
- * that case keeps working exactly as it did before this fix.
382
+ * resolution finds nothing (e.g. a project root with no local vitest
383
+ * install) so that case keeps working exactly as it did before this fix.
362
384
  *
363
385
  * The property this buys — one module instance shared by the server and the
364
386
  * project's test files — is pinned by `vitest-node-module-identity.test.ts`
@@ -369,12 +391,7 @@ async function validateProjectRoot(projectRoot, ctxCwd) {
369
391
  * @internal exported for tests
370
392
  */
371
393
  function resolveVitestNodeEntry(root) {
372
- try {
373
- const req = createRequire(join(root, "__vitest-agent-resolver__.js"));
374
- return pathToFileURL(req.resolve("vitest/node")).href;
375
- } catch {
376
- return "vitest/node";
377
- }
394
+ return Option.getOrElse(ModuleResolution.resolve("vitest/node", { from: root }), () => "vitest/node");
378
395
  }
379
396
  /**
380
397
  * Issue #461: the MCP server is long-lived. When the lockfile is regenerated
@@ -382,36 +399,59 @@ function resolveVitestNodeEntry(root) {
382
399
  * store directory (peer-hash change), so the entry resolved for this process
383
400
  * no longer exists and every run would otherwise fail opaquely inside the fork
384
401
  * pool ("Worker forks emitted error", total: 0). Returns an actionable message
385
- * when `entry` is a `file://` URL whose path is missing on disk, else `null`.
386
- * The bare `"vitest/node"` fallback is not a path and is never checked. The
387
- * check is on the exact path that will be imported, because Node's require
388
- * resolution caches can hand back a stale path in a long-lived process.
402
+ * when `entry` (an absolute path or a `file://` URL) is missing on disk
403
+ * (`ModuleResolution.isStale`, which checks the exact path that will be
404
+ * imported), else `null`. The bare `"vitest/node"` fallback is not a path and
405
+ * is never stale.
389
406
  *
390
407
  * @internal exported for tests
391
408
  */
392
409
  function staleInstallMessage(entry) {
393
- if (!entry.startsWith("file:")) return null;
394
- const path = fileURLToPath(entry);
395
- if (existsSync(path)) return null;
396
- return `The resolved vitest entry "${path}" no longer exists on disk. The install changed since this vitest-agent MCP server loaded it (for example the lockfile was regenerated by a dependency install or branch switch). Restart the vitest-agent MCP server (in Claude Code: /mcp) or restart the session, then retry.`;
410
+ if (!ModuleResolution.isStale(entry)) return null;
411
+ return `The resolved vitest entry "${entry.startsWith("file:") ? fileURLToPath(entry) : entry}" no longer exists on disk. The install changed since this vitest-agent MCP server loaded it (for example the lockfile was regenerated by a dependency install or branch switch). Restart the vitest-agent MCP server (in Claude Code: /mcp) or restart the session, then retry.`;
397
412
  }
398
413
  /**
399
- * Indirection seam around `import(<vitest/node entry>)`. vitest's own
414
+ * The test seam around resolving and importing `vitest/node`. vitest's own
400
415
  * vite-node externalizes "vitest"/"vitest/node" for every importer, and
401
- * `vi.mock("vitest/node", ...)` only special-cases AST-literal
402
- * `import("vitest/node")` call sites for interception — a computed
403
- * specifier (unavoidable here; see `resolveVitestNodeEntry`) silently
404
- * bypasses that interception and loads the real module. Tests substitute
405
- * `.load` directly (mutating this shared object's property — no `vi.mock`
406
- * required) instead of trying to mock the module.
416
+ * `vi.mock("vitest/node", ...)` only intercepts AST-literal
417
+ * `import("vitest/node")` call sites — a computed specifier (unavoidable
418
+ * here; see `resolveVitestNodeEntry`) silently bypasses it. So the import is
419
+ * `ModuleResolution.load` with an injectable `importer`: tests substitute
420
+ * `.load` (the importer, called with the entry as a `file:` URL or the bare
421
+ * fallback specifier) and `.resolveEntry` by mutating this shared object,
422
+ * no `vi.mock` required. The production importer is this module's own
423
+ * `import()`, never `ModuleResolution.load`'s default: the bare
424
+ * `"vitest/node"` fallback must resolve from this package, which declares
425
+ * the `vitest` peer, not from `@effected/commands`, which does not.
407
426
  *
408
427
  * @internal exported for tests
409
428
  */
410
429
  const vitestLoader = {
411
430
  resolveEntry: (root) => resolveVitestNodeEntry(root),
412
- load: (entry) => import(entry)
431
+ load: (url) => import(
432
+ /* @vite-ignore */
433
+ url
434
+ )
413
435
  };
414
- const runTestsSemaphore = Effect.runSync(Semaphore.make(1));
436
+ /**
437
+ * The one-permit lane every `run_tests` body runs in. The body assigns the
438
+ * active attribution UUIDs into `process.env.VITEST_AGENT_*`, enters the run's
439
+ * root with `chdir`, and then awaits `createVitest` / `vitest.start`, which
440
+ * spawns the worker pool that snapshots env (and cwd) at spawn time. Two
441
+ * interleaved tool calls would race: caller B's env assignment can land
442
+ * between A's assignment and A's worker spawn, attributing A's results to B's
443
+ * agent. `Exclusive` keeps the whole body atomic against every other
444
+ * `run_tests` call in this process; its scope restores env and cwd before the
445
+ * next call starts, and a client cancel (an interrupt of the request fiber)
446
+ * only drops the response — the permit is held until the body ends.
447
+ */
448
+ const runTestsLane = Effect.runSync(Exclusive.make);
449
+ /**
450
+ * How long a run may hold the lane beyond its own test timeout: config
451
+ * loading (`createVitest`), the classification read, and teardown. The test
452
+ * timeout itself bounds only `vitest.start`, as it always has.
453
+ */
454
+ const LANE_ALLOWANCE_MS = 12e4;
415
455
  /**
416
456
  * Coerce unknown Vitest unhandled errors into VitestModuleError shape.
417
457
  *
@@ -442,6 +482,7 @@ const RunTestsInput = Schema.Struct({
442
482
  tags: Schema.optionalKey(TagFilter).annotate({ description: "Structured tag filter; all/any/none AND together with each other and with project/files" }),
443
483
  passWithNoTests: Schema.optionalKey(Schema.Boolean).annotate({ description: "Per-call override of Vitest's native test.passWithNoTests" }),
444
484
  timeout: Schema.optionalKey(Schema.Finite).annotate({ description: "Timeout in seconds (default: 120)" }),
485
+ _callerCwd: Schema.optionalKey(Schema.String).annotate({ description: "Hook-injected caller working directory; do not pass manually." }),
445
486
  _sessionContext: Schema.optionalKey(Schema.Struct({
446
487
  chatId: Schema.String,
447
488
  conversationId: Schema.String,
@@ -519,6 +560,7 @@ function toNoMatch(scope, projectRoot) {
519
560
  */
520
561
  function toOkPayload(args) {
521
562
  const { scope } = args;
563
+ const suggestedActions = [...args.suggestedActions ?? [], ...args.runDiagnostics !== void 0 ? suggestedActionsFromRunDiagnostics(args.runDiagnostics) : []];
522
564
  return {
523
565
  kind: "ok",
524
566
  ...scope.project !== void 0 && { project: scope.project },
@@ -532,7 +574,8 @@ function toOkPayload(args) {
532
574
  classifications: args.classifications ? Object.fromEntries(args.classifications) : {},
533
575
  discoveryLastScannedAt: args.discoveryLastScannedAt ?? null,
534
576
  scopedNote: args.scopedNote,
535
- ...args.suggestedActions !== void 0 && args.suggestedActions.length > 0 ? { suggestedActions: args.suggestedActions } : {}
577
+ ...suggestedActions.length > 0 ? { suggestedActions } : {},
578
+ ...args.runDiagnostics !== void 0 && { runDiagnostics: args.runDiagnostics }
536
579
  };
537
580
  }
538
581
  /**
@@ -547,27 +590,138 @@ function toOkPayload(args) {
547
590
  */
548
591
  const makeBestEffortFork = (services) => (effect) => Effect.runFork(Effect.provideContext(effect.pipe(Effect.ignore), services));
549
592
  /**
550
- * The run body, promise-shaped because it drives Vitest's promise API and
551
- * the AsyncLocalStorage stdio capture; every failure is folded into the
552
- * `{ kind: "error" }` envelope, so it never rejects.
593
+ * A rejected step's message. Exception-safe: a hostile thrown value (a
594
+ * throwing `message` getter or `toString`) still yields a string, so the
595
+ * `{ kind: "error" }` envelope always comes back.
596
+ */
597
+ const errorMessage = (err) => {
598
+ try {
599
+ return err instanceof Error ? err.message : String(err);
600
+ } catch {
601
+ return coerceErrorField(err, "message") ?? "<unserializable error>";
602
+ }
603
+ };
604
+ /**
605
+ * A promise-returning Vitest step, run through the lane's promise. By default
606
+ * an interrupt aborts, then waits for the promise to settle; `abandon` resumes
607
+ * at once instead, for a promise that ignores its signal.
608
+ */
609
+ const step = (evaluate, options) => Exclusive.promise({
610
+ try: evaluate,
611
+ catch: (cause) => new RunStepFailure({ cause }),
612
+ ...options
613
+ });
614
+ /** The Node platform the run's root validation, config walk and git reads use. */
615
+ const onNodePlatform = (effect) => Effect.provide(effect, NodeWalkPlatform);
616
+ /**
617
+ * The run itself, once the root, config and `vitest/node` entry are settled:
618
+ * every step that can reject fails as `RunStepFailure`, which the body folds
619
+ * into `{ kind: "error" }` along with any throw, and every process-global change (cwd, the coverage temp dir, the Vitest instance) is
620
+ * released by the lane's scope on every exit path.
553
621
  */
554
- const runTestsBody = async (input, ctx) => {
622
+ const runVitest = (args) => Effect.gen(function* () {
623
+ const { input, ctx, scope, resolvedRoot, anchoredConfig, vitestEntry } = args;
624
+ const { files, project, resolvedExpression, hasFilter } = scope;
625
+ const timeoutSeconds = input.timeout ?? 120;
626
+ const { createVitest } = yield* step(() => ModuleResolution.load(vitestEntry, { importer: vitestLoader.load }));
627
+ yield* ProcessScope.chdir(process, resolvedRoot);
628
+ const coverageDir = yield* coverageTempDirectory.pipe(Effect.mapError((cause) => new RunStepFailure({ cause })));
629
+ const nullStream = yield* Effect.acquireRelease(Effect.sync(StdioCapture.nullWritable), (stream) => Effect.sync(() => stream.destroy()));
630
+ const vitest = yield* Effect.acquireRelease(step(() => createVitest({
631
+ root: resolvedRoot,
632
+ ...anchoredConfig !== void 0 ? { config: anchoredConfig } : {},
633
+ run: true,
634
+ coverage: { reportsDirectory: coverageDir },
635
+ ...project ? { project } : {},
636
+ ...resolvedExpression !== null ? { tagsFilter: [resolvedExpression] } : {},
637
+ ...input.passWithNoTests !== void 0 ? { passWithNoTests: input.passWithNoTests } : {}
638
+ }, {}, {
639
+ stdout: nullStream,
640
+ stderr: nullStream
641
+ })), (instance) => Effect.tryPromise(() => instance.close()).pipe(Effect.ignore));
642
+ const capture = yield* RunTestsStdio;
643
+ const started = yield* step(() => capture.run(DISCARD_STDIO, () => vitest.start(files.length > 0 ? files : void 0)), { abandon: true }).pipe(Effect.timeout(timeoutSeconds * 1e3), Effect.map((value) => ({
644
+ outcome: "ok",
645
+ value
646
+ })), Effect.catchTag("TimeoutError", () => Effect.succeed({ outcome: "timeout" })));
647
+ if (started.outcome === "timeout") return {
648
+ kind: "timeout",
649
+ timeoutSeconds
650
+ };
651
+ const result = started.value;
652
+ const testModules = result.testModules;
653
+ const unhandledErrors = coerceErrors(result.unhandledErrors);
654
+ if (isNoMatch(scope, result.testModules.length, unhandledErrors.length)) return toNoMatch(scope, resolvedRoot);
655
+ const preliminaryReason = unhandledErrors.length > 0 || result.testModules.some((m) => m.state() === "failed") ? "failed" : "passed";
656
+ const baseReport = buildAgentReport(testModules, unhandledErrors, preliminaryReason, { omitPassingTests: true });
657
+ const leaks = buildConsoleLeaks(collectConsoleLeakEntries(vitest.state.getFiles()));
658
+ const strayOutput = readStrayOutput(vitest.logger);
659
+ const configSuggestedActions = readConfigSuggestedActions(vitest);
660
+ const runDiagnostics = readRunDiagnostics(vitest);
661
+ const report = {
662
+ ...baseReport,
663
+ ...leaks !== void 0 && { consoleLeaks: leaks },
664
+ ...strayOutput !== void 0 && { strayOutput }
665
+ };
666
+ const classifications = yield* Effect.gen(function* () {
667
+ const reader = yield* DataReader;
668
+ const projects = project ? [project] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => r.project)));
669
+ const entries = [];
670
+ for (const p of projects) {
671
+ const tests = yield* reader.listTests(p, {});
672
+ for (const t of tests) if (t.classification != null) entries.push([t.fullName, t.classification]);
673
+ }
674
+ return new Map(entries);
675
+ }).pipe(Effect.catchCause(() => Effect.succeed(void 0)));
676
+ const chatId = ctx.currentSessionId.get();
677
+ if (chatId !== null) makeBestEffortFork(yield* Effect.context())(Effect.gen(function* () {
678
+ yield* (yield* DataStore).associateLatestRunWithSession({
679
+ chatId,
680
+ invocationMethod: "mcp"
681
+ });
682
+ }));
683
+ let scopedNote = null;
684
+ if (hasFilter) {
685
+ const totalFileCount = yield* step(() => vitest.globTestSpecifications()).pipe(Effect.map((specs) => specs.length), Effect.orElseSucceed(() => void 0));
686
+ scopedNote = formatScopedCoverageNote(testModules.length, totalFileCount);
687
+ }
688
+ return toOkPayload({
689
+ scope,
690
+ projectRoot: resolvedRoot,
691
+ report,
692
+ classifications,
693
+ discoveryLastScannedAt: readDiscoveryLastScannedAt(),
694
+ scopedNote,
695
+ ...configSuggestedActions !== void 0 && { suggestedActions: configSuggestedActions },
696
+ ...runDiagnostics !== void 0 && { runDiagnostics }
697
+ });
698
+ });
699
+ /**
700
+ * The run body, inside the lane: input checks that return the tool's
701
+ * `{ kind: "error" }` result before anything process-global changes, then
702
+ * the attribution env and the run. Never fails.
703
+ */
704
+ const runTestsBody = (input, ctx) => Effect.gen(function* () {
555
705
  const derived = deriveRunScope(input);
556
706
  if (Result.isFailure(derived)) return {
557
707
  kind: "error",
558
708
  message: derived.failure
559
709
  };
560
710
  const scope = derived.success;
561
- const { files, project, resolvedExpression, hasFilter } = scope;
562
- const projectRootValidation = await validateProjectRoot(input.projectRoot, ctx.cwd);
711
+ const projectRootValidation = yield* Effect.promise(() => validateProjectRoot(input.projectRoot, ctx.cwd));
563
712
  if (!projectRootValidation.ok) return {
564
713
  kind: "error",
565
714
  message: projectRootValidation.message
566
715
  };
567
716
  const resolvedRoot = projectRootValidation.root;
717
+ const callerCheck = yield* Effect.promise(() => checkCallerWorktree(input._callerCwd, resolvedRoot));
718
+ if (!callerCheck.ok) return {
719
+ kind: "error",
720
+ message: callerCheck.message
721
+ };
568
722
  let anchoredConfig;
569
723
  if (input.projectRoot !== void 0) {
570
- const found = await runOnNodePlatform(resolveAnchoredConfigFile(resolvedRoot));
724
+ const found = yield* onNodePlatform(resolveAnchoredConfigFile(resolvedRoot));
571
725
  if (found === null) return {
572
726
  kind: "error",
573
727
  message: `No vitest.config.* or vite.config.* was found at or above projectRoot "${resolvedRoot}" within the repository. Vitest 5 does not search ancestor directories, so this run would collect no tests and load no plugins. Pass a projectRoot that contains a Vitest config, or omit projectRoot to anchor automatically.`
@@ -581,157 +735,61 @@ const runTestsBody = async (input, ctx) => {
581
735
  reason: "stale-install",
582
736
  message: staleMessage
583
737
  };
584
- const timeoutMs = (input.timeout ?? 120) * 1e3;
585
- const recovered = input._sessionContext ?? null ?? ctx.sessionContext.get();
586
- if (recovered !== null) {
587
- process.env.VITEST_AGENT_CHAT_ID = recovered.chatId;
588
- process.env.VITEST_AGENT_CONVERSATION_ID = recovered.conversationId;
589
- process.env.VITEST_AGENT_AGENT_ID = recovered.mainAgentId;
590
- }
591
- const nullStream = new Writable({ write(_chunk, _encoding, cb) {
592
- cb();
593
- } });
594
- const { createVitest } = await vitestLoader.load(vitestEntry);
595
- let vitest;
596
- let covOverride;
597
- let previousCwd;
598
- try {
599
- previousCwd = process.cwd();
600
- } catch {
601
- previousCwd = void 0;
602
- }
603
- try {
604
- process.chdir(resolvedRoot);
605
- covOverride = makeCoverageDirOverride();
606
- vitest = await createVitest({
607
- root: resolvedRoot,
608
- ...anchoredConfig !== void 0 ? { config: anchoredConfig } : {},
609
- run: true,
610
- coverage: covOverride.coverage,
611
- ...project ? { project } : {},
612
- ...resolvedExpression !== null ? { tagsFilter: [resolvedExpression] } : {},
613
- ...input.passWithNoTests !== void 0 ? { passWithNoTests: input.passWithNoTests } : {}
614
- }, {}, {
615
- stdout: nullStream,
616
- stderr: nullStream
617
- });
618
- const localVitest = vitest;
619
- const startOutcome = await withStdioCaptured(nullStream, () => Effect.runPromise(Effect.tryPromise({
620
- try: () => localVitest.start(files.length > 0 ? files : void 0),
621
- catch: (cause) => new VitestStartFailure({ cause })
622
- }).pipe(Effect.timeout(timeoutMs), Effect.map((value) => ({
623
- outcome: "ok",
624
- value
625
- })), Effect.catchTag("TimeoutError", () => Effect.succeed({ outcome: "timeout" })), Effect.catchTag("VitestStartFailure", (e) => Effect.succeed({
626
- outcome: "failed",
627
- cause: e.cause
628
- })))));
629
- if (startOutcome.outcome === "timeout") return {
630
- kind: "timeout",
631
- timeoutSeconds: input.timeout ?? 120
632
- };
633
- if (startOutcome.outcome === "failed") throw startOutcome.cause;
634
- const result = startOutcome.value;
635
- const testModules = result.testModules;
636
- const unhandledErrors = coerceErrors(result.unhandledErrors);
637
- if (isNoMatch(scope, result.testModules.length, unhandledErrors.length)) return toNoMatch(scope, resolvedRoot);
638
- const preliminaryReason = unhandledErrors.length > 0 || result.testModules.some((m) => m.state() === "failed") ? "failed" : "passed";
639
- const baseReport = buildAgentReport(testModules, unhandledErrors, preliminaryReason, { omitPassingTests: true });
640
- const leaks = buildConsoleLeaks(collectConsoleLeakEntries(localVitest.state.getFiles()));
641
- const strayOutput = readStrayOutput(localVitest.logger);
642
- const configSuggestedActions = readConfigSuggestedActions(localVitest);
643
- const report = {
644
- ...baseReport,
645
- ...leaks !== void 0 && { consoleLeaks: leaks },
646
- ...strayOutput !== void 0 && { strayOutput }
647
- };
648
- let classifications;
649
- try {
650
- classifications = await ctx.runPromise(Effect.gen(function* () {
651
- const reader = yield* DataReader;
652
- const projects = project ? [project] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => r.project)));
653
- const entries = [];
654
- for (const p of projects) {
655
- const tests = yield* reader.listTests(p, {});
656
- for (const t of tests) if (t.classification != null) entries.push([t.fullName, t.classification]);
657
- }
658
- return new Map(entries);
659
- }));
660
- } catch {}
661
- const chatId = ctx.currentSessionId.get();
662
- if (chatId !== null) ctx.runFork(Effect.gen(function* () {
663
- yield* (yield* DataStore).associateLatestRunWithSession({
664
- chatId,
665
- invocationMethod: "mcp"
666
- });
667
- }));
668
- let scopedNote = null;
669
- if (hasFilter) {
670
- const testedFileCount = testModules.length;
671
- let totalFileCount;
672
- try {
673
- totalFileCount = (await localVitest.globTestSpecifications()).length;
674
- } catch {}
675
- scopedNote = formatScopedCoverageNote(testedFileCount, totalFileCount);
676
- }
677
- return toOkPayload({
678
- scope,
679
- projectRoot: resolvedRoot,
680
- report,
681
- classifications,
682
- discoveryLastScannedAt: readDiscoveryLastScannedAt(),
683
- scopedNote,
684
- ...configSuggestedActions !== void 0 && { suggestedActions: configSuggestedActions }
685
- });
686
- } catch (err) {
687
- let message;
688
- try {
689
- message = err instanceof Error ? err.message : String(err);
690
- } catch {
691
- message = coerceErrorField(err, "message") ?? "<unserializable error>";
692
- }
693
- return {
738
+ const recovered = input._sessionContext ?? ctx.sessionContext.get();
739
+ if (recovered !== null) yield* ProcessScope.env(process.env, {
740
+ VITEST_AGENT_CHAT_ID: recovered.chatId,
741
+ VITEST_AGENT_CONVERSATION_ID: recovered.conversationId,
742
+ VITEST_AGENT_AGENT_ID: recovered.mainAgentId
743
+ });
744
+ return yield* runVitest({
745
+ input,
746
+ ctx,
747
+ scope,
748
+ resolvedRoot,
749
+ anchoredConfig,
750
+ vitestEntry
751
+ }).pipe(Effect.catchTags({
752
+ RunStepFailure: (e) => Effect.succeed({
694
753
  kind: "error",
695
- message
696
- };
697
- } finally {
698
- try {
699
- await vitest?.close();
700
- } finally {
701
- try {
702
- if (previousCwd !== void 0) process.chdir(previousCwd);
703
- } catch {}
704
- nullStream.destroy();
705
- if (covOverride !== void 0) try {
706
- rmSync(covOverride.dir, {
707
- recursive: true,
708
- force: true
709
- });
710
- } catch {}
711
- }
712
- }
713
- };
754
+ message: errorMessage(e.cause)
755
+ }),
756
+ ProcessScopeError: (e) => Effect.succeed({
757
+ kind: "error",
758
+ message: errorMessage(e.cause)
759
+ })
760
+ }), Effect.catchDefect((defect) => Effect.succeed({
761
+ kind: "error",
762
+ message: errorMessage(defect)
763
+ })));
764
+ });
714
765
  /**
715
- * Handler for {@link runTestsTool}. Serialized through a one-permit
716
- * semaphore: the body assigns the active attribution UUIDs into
717
- * `process.env.VITEST_AGENT_*` and then awaits `createVitest` /
718
- * `vitest.start`, which spawns the worker pool that snapshots env at
719
- * spawn time — two interleaved calls would race, attributing one call's
720
- * results to the other's agent.
766
+ * Handler for {@link runTestsTool}. The body runs in the one-permit
767
+ * `Exclusive` lane: it assigns the active attribution UUIDs into
768
+ * `process.env.VITEST_AGENT_*`, enters the run's root, and then awaits
769
+ * `createVitest` / `vitest.start`, which spawns the worker pool that
770
+ * snapshots env and cwd at spawn time — two interleaved calls would race,
771
+ * attributing one call's results to the other's agent. A client cancel
772
+ * drops the response; the next call waits for the real run to end.
721
773
  *
722
774
  * @public
723
775
  */
724
776
  const handleRunTests = (input) => Effect.gen(function* () {
725
777
  const session = yield* McpSession;
726
- const services = yield* Effect.context();
727
778
  const ctx = {
728
779
  cwd: session.cwd,
729
780
  currentSessionId: session.currentSessionId,
730
- sessionContext: session.sessionContext,
731
- runPromise: (effect) => Effect.runPromise(Effect.provideContext(effect, services)),
732
- runFork: makeBestEffortFork(services)
781
+ sessionContext: session.sessionContext
733
782
  };
734
- return yield* Semaphore.withPermit(runTestsSemaphore, Effect.uninterruptible(Effect.promise(() => runTestsBody(input, ctx))));
783
+ const timeoutSeconds = input.timeout ?? 120;
784
+ const fs = yield* Effect.serviceOption(FileSystem.FileSystem);
785
+ const platformFs = Option.match(fs, {
786
+ onNone: () => NodeFileSystem.layer,
787
+ onSome: (service) => Layer.succeed(FileSystem.FileSystem, service)
788
+ });
789
+ return yield* runTestsLane.run(runTestsBody(input, ctx), { timeout: timeoutSeconds * 1e3 + LANE_ALLOWANCE_MS }).pipe(Effect.catchTag("ExclusiveTimeout", () => Effect.succeed({
790
+ kind: "timeout",
791
+ timeoutSeconds
792
+ })), Effect.provide(platformFs));
735
793
  });
736
794
  /**
737
795
  * The Effect-native `run_tests` tool.
@@ -739,7 +797,7 @@ const handleRunTests = (input) => Effect.gen(function* () {
739
797
  * @public
740
798
  */
741
799
  const runTestsTool = Tool.make("run_tests", {
742
- description: "Use to run Vitest tests, with optional file, project, and tag filters. structuredContent carries the typed AgentReport plus per-test classifications (discriminate on `kind`: ok, timeout, error, no-match). Unknown parameters are rejected — accepted keys are files, project, tags, passWithNoTests, timeout, projectRoot. When projectRoot is omitted, the server anchors the Vitest root at the directory of the vitest (or vite) config Vitest would load anyway, walking up from its boot dir and stopping at the git root — so a server booted inside a package subtree still resolves the root config's relative globalSetup/setupFiles correctly. projectRoot overrides that for this call and is used verbatim, but only after validation: it must be an existing directory belonging to the same git repository as ctx.cwd (checked via `git rev-parse --path-format=absolute --git-common-dir`, which needs git 2.31 or newer and is identical across a repo and all its worktrees, including a sibling worktree checked out from the same repo). A path in a different repository, or a non-existent path, is rejected with `{ kind: \"error\" }` naming both paths — never a silent fallback to ctx.cwd. The resolved root actually used is always echoed back on success. An error result with `reason: \"stale-install\"` means the vitest install was replaced under this long-lived server (the lockfile was regenerated) and the resolved vitest/node entry is gone; restart the MCP server (/mcp in Claude Code) rather than retrying. The legacy format=json arg is dropped — structuredContent supersedes it.",
800
+ description: "Use to run Vitest tests, with optional file, project, and tag filters. structuredContent carries the typed AgentReport plus per-test classifications (discriminate on `kind`: ok, timeout, error, no-match). Unknown parameters are rejected — accepted keys are files, project, tags, passWithNoTests, timeout, projectRoot. When projectRoot is omitted, the server anchors the Vitest root at the directory of the vitest (or vite) config Vitest would load anyway, walking up from its boot dir and stopping at the git root — so a server booted inside a package subtree still resolves the root config's relative globalSetup/setupFiles correctly. projectRoot overrides that for this call and is used verbatim, but only after validation: it must be an existing directory belonging to the same git repository as ctx.cwd (checked via `git rev-parse --path-format=absolute --git-common-dir`, which needs git 2.31 or newer and is identical across a repo and all its worktrees, including a sibling worktree checked out from the same repo). A path in a different repository, or a non-existent path, is rejected with `{ kind: \"error\" }` naming both paths — never a silent fallback to ctx.cwd. The resolved root actually used is always echoed back on success. When the plugin hook supplies the caller working directory (the hook-injected _callerCwd), a call from a different working tree of the same repository than the resolved root is refused with `{ kind: \"error\" }` naming both trees; pass projectRoot (your own worktree path) to run there. An absent or indeterminate caller directory never refuses. An error result with `reason: \"stale-install\"` means the vitest install was replaced under this long-lived server (the lockfile was regenerated) and the resolved vitest/node entry is gone; restart the MCP server (/mcp in Claude Code) rather than retrying. The legacy format=json arg is dropped — structuredContent supersedes it.",
743
801
  parameters: RunTestsInput,
744
802
  success: RunTestsResult,
745
803
  dependencies: [
@@ -749,4 +807,4 @@ const runTestsTool = Tool.make("run_tests", {
749
807
  ]
750
808
  }).annotate(Tool.Title, "Run tests").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false);
751
809
 
752
- export { RUN_TESTS_ERROR_REASONS, RunTestsInput, RunTestsResult, coerceErrors, composeTagExpression, deriveRunScope, handleRunTests, isNoMatch, makeBestEffortFork, makeCoverageDirOverride, readConfigSuggestedActions, readDiscoveryLastScannedAt, resolveAnchoredConfigFile, resolveConfigAnchoredRoot, resolveVitestNodeEntry, runTestsSemaphore, runTestsTool, sanitizeTestArgs, staleInstallMessage, toNoMatch, toOkPayload, validateProjectRoot, vitestLoader, withStdioCaptured };
810
+ export { RUN_TESTS_ERROR_REASONS, RunTestsInput, RunTestsResult, RunTestsStdio, checkCallerWorktree, coerceErrors, composeTagExpression, deriveRunScope, handleRunTests, isNoMatch, makeBestEffortFork, readConfigSuggestedActions, readDiscoveryLastScannedAt, readRunDiagnostics, resolveAnchoredConfigFile, resolveConfigAnchoredRoot, resolveVitestNodeEntry, runTestsTool, sanitizeTestArgs, staleInstallMessage, toNoMatch, toOkPayload, validateProjectRoot, vitestLoader };