@vitest-agent/mcp 4.0.2 → 4.0.3

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/idempotency.js CHANGED
@@ -3,6 +3,13 @@ import { DataReader, DataStore } from "@vitest-agent/engine";
3
3
 
4
4
  //#region src/idempotency.ts
5
5
  /**
6
+ * Narrow a raw tool payload to the action-shaped object every consolidated
7
+ * spec keys on: a non-null object carrying an `action` key. Anything else
8
+ * (including a legacy non-action payload) yields `null`, which every spec
9
+ * maps to "not idempotent".
10
+ */
11
+ const asActionInput = (input) => input !== null && typeof input === "object" && "action" in input ? input : null;
12
+ /**
6
13
  * Registered idempotency specs for mutation tools.
7
14
  *
8
15
  * `hypothesis validate` is covered (key: `validate:${id}:${outcome}`).
@@ -17,27 +24,17 @@ const idempotencyKeys = [
17
24
  {
18
25
  procedurePath: "hypothesis",
19
26
  deriveKey: (input) => {
20
- if (input === null || typeof input !== "object" || !("action" in input)) return null;
21
- const i = input;
27
+ const i = asActionInput(input);
28
+ if (i === null) return null;
22
29
  if (i.action === "validate" && typeof i.id === "number" && typeof i.outcome === "string") return `validate:${i.id}:${i.outcome}`;
23
30
  return null;
24
31
  }
25
32
  },
26
- {
27
- procedurePath: "_legacy_hypothesis_validate",
28
- deriveKey: (input) => {
29
- if (input !== null && typeof input === "object" && "id" in input && "outcome" in input && typeof input.id === "number" && typeof input.outcome === "string") {
30
- const i = input;
31
- return `${i.id}:${i.outcome}`;
32
- }
33
- return null;
34
- }
35
- },
36
33
  {
37
34
  procedurePath: "tdd_task",
38
35
  deriveKey: (input) => {
39
- if (input === null || typeof input !== "object" || !("action" in input)) return null;
40
- const i = input;
36
+ const i = asActionInput(input);
37
+ if (i === null) return null;
41
38
  if (i.action === "start" && typeof i.goal === "string") {
42
39
  if (typeof i.runId === "string") {
43
40
  if (typeof i.sessionId === "number") return `start:sid:${i.sessionId}:run:${i.runId}`;
@@ -53,8 +50,8 @@ const idempotencyKeys = [
53
50
  {
54
51
  procedurePath: "tdd_goal",
55
52
  deriveKey: (input) => {
56
- if (input === null || typeof input !== "object" || !("action" in input)) return null;
57
- const i = input;
53
+ const i = asActionInput(input);
54
+ if (i === null) return null;
58
55
  if (i.action === "create" && typeof i.tddTaskId === "number" && typeof i.goal === "string") return `create:${i.tddTaskId}:${i.goal}`;
59
56
  return null;
60
57
  }
@@ -62,8 +59,8 @@ const idempotencyKeys = [
62
59
  {
63
60
  procedurePath: "tdd_behavior",
64
61
  deriveKey: (input) => {
65
- if (input === null || typeof input !== "object" || !("action" in input)) return null;
66
- const i = input;
62
+ const i = asActionInput(input);
63
+ if (i === null) return null;
67
64
  if (i.action === "create" && typeof i.goalId === "number" && typeof i.behavior === "string") return `create:${i.goalId}:${i.behavior}`;
68
65
  return null;
69
66
  }
package/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Context, Effect, Layer, Schema, Stdio } from "effect";
2
- import { DataReader, DataStore, OutputRenderer, ProjectDiscovery, SessionContext } from "@vitest-agent/engine";
2
+ import { DataReader, DataStore, ProjectDiscovery, SessionContext } from "@vitest-agent/engine";
3
3
  import { McpSchema, Tool, Toolkit } from "effect/unstable/ai";
4
4
  import "@vitest-agent/sdk";
5
5
  //#region src/annotations.d.ts
@@ -192,7 +192,7 @@ interface ServerLayerOptions {
192
192
  * @param options - the advertised server version
193
193
  * @public
194
194
  */
195
- export declare const ServerLayer: (options: ServerLayerOptions) => Layer.Layer<never, never, PlatformServices | McpSession | DataReader | DataStore | ProjectDiscovery | OutputRenderer>;
195
+ export declare const ServerLayer: (options: ServerLayerOptions) => Layer.Layer<never, never, PlatformServices | McpSession | DataReader | DataStore | ProjectDiscovery>;
196
196
  //#endregion
197
197
  //#region src/tools/ping.d.ts
198
198
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/mcp",
3
- "version": "4.0.2",
3
+ "version": "4.0.3",
4
4
  "private": false,
5
5
  "description": "Model Context Protocol server for vitest-agent. Exposes 53 tools for agent access to test data, TDD lifecycle, and session management.",
6
6
  "keywords": [
@@ -45,8 +45,8 @@
45
45
  "dependencies": {
46
46
  "@effect/platform-node": "4.0.0-rc.115",
47
47
  "@effect/sql-sqlite-node": "4.0.0-rc.115",
48
- "@vitest-agent/engine": "0.1.2",
49
- "@vitest-agent/sdk": "5.0.0",
48
+ "@vitest-agent/engine": "0.1.3",
49
+ "@vitest-agent/sdk": "5.0.1",
50
50
  "effect": "4.0.0-rc.115"
51
51
  },
52
52
  "peerDependencies": {
@@ -224,18 +224,15 @@ function findConfigInDir(dir) {
224
224
  }
225
225
  return null;
226
226
  }
227
- function dirHasVitestOrViteConfig(dir) {
228
- return findConfigInDir(dir) !== null;
229
- }
230
227
  /**
231
- * Walk UP from `startDir` looking for the vitest/vite config file, returning
232
- * its absolute path. Same walk and same git-root bound as
233
- * `resolveConfigAnchoredRoot`; returns `null` when no config is found in
234
- * range or when anything about the walk throws.
235
- *
236
- * @internal exported for tests
228
+ * The one walk both anchoring helpers share: step UP from `startDir`
229
+ * until a vitest/vite config file is found, returning its absolute path.
230
+ * Bounded at the git root (inclusive the directory containing `.git`
231
+ * is still examined before the walk stops) and at the filesystem root.
232
+ * Returns `null` when no config is found in range or when anything about
233
+ * the walk throws.
237
234
  */
238
- function resolveAnchoredConfigFile(startDir) {
235
+ function walkUpToConfigFile(startDir) {
239
236
  try {
240
237
  let dir = resolve(startDir);
241
238
  for (;;) {
@@ -251,28 +248,25 @@ function resolveAnchoredConfigFile(startDir) {
251
248
  }
252
249
  }
253
250
  /**
251
+ * Walk UP from `startDir` looking for the vitest/vite config file, returning
252
+ * its absolute path, or `null` when none is found in range.
253
+ *
254
+ * @internal exported for tests
255
+ */
256
+ function resolveAnchoredConfigFile(startDir) {
257
+ return walkUpToConfigFile(startDir);
258
+ }
259
+ /**
254
260
  * Walk UP from `startDir` looking for the vitest/vite config Vitest would
255
- * load anyway, returning the directory that holds it. Bounded at the git
256
- * root (inclusive the directory containing `.git` is still examined
257
- * before the walk stops). Returns `startDir` unchanged when no config is
258
- * found in range, or when anything about the walk throws. See the
259
- * issue #259 comment above `validateProjectRoot` for the full rationale.
261
+ * load anyway, returning the directory that holds it. Returns `startDir`
262
+ * unchanged when no config is found in range. See the issue #259 comment
263
+ * above `validateProjectRoot` for the full rationale.
260
264
  *
261
265
  * @internal exported for tests
262
266
  */
263
267
  function resolveConfigAnchoredRoot(startDir) {
264
- try {
265
- let dir = resolve(startDir);
266
- for (;;) {
267
- if (dirHasVitestOrViteConfig(dir)) return dir;
268
- if (existsSync(join(dir, ".git"))) return startDir;
269
- const parent = dirname(dir);
270
- if (parent === dir) return startDir;
271
- dir = parent;
272
- }
273
- } catch {
274
- return startDir;
275
- }
268
+ const found = walkUpToConfigFile(startDir);
269
+ return found === null ? startDir : dirname(found);
276
270
  }
277
271
  /**
278
272
  * Validate an optional caller-supplied `projectRoot` against `ctxCwd`
@@ -536,6 +530,17 @@ const RunTestsInput = Schema.Struct({
536
530
  })).annotate({ description: "Hook-injected session attribution UUIDs; do not pass manually." })
537
531
  });
538
532
  /**
533
+ * Build the best-effort runner for a `RunTestsContext`: forks the effect
534
+ * as a fiber on the provided services with its expected failure channel
535
+ * ignored (`Effect.ignore`), so a typed failure never touches the tool
536
+ * result (issue #330). Defects and interrupts are NOT absorbed — a bug in
537
+ * the forked effect still surfaces through the runtime's fiber logging
538
+ * rather than being hidden. Returns the fiber so a test can await its exit.
539
+ *
540
+ * @internal
541
+ */
542
+ const makeBestEffortFork = (services) => (effect) => Effect.runFork(Effect.provideContext(effect.pipe(Effect.ignore), services));
543
+ /**
539
544
  * The run body, promise-shaped because it drives Vitest's promise API and
540
545
  * the AsyncLocalStorage stdio capture; every failure is folded into the
541
546
  * `{ kind: "error" }` envelope, so it never rejects.
@@ -643,12 +648,12 @@ const runTestsBody = async (input, ctx) => {
643
648
  }));
644
649
  } catch {}
645
650
  const chatId = ctx.currentSessionId.get();
646
- if (chatId !== null) ctx.runPromise(Effect.gen(function* () {
651
+ if (chatId !== null) ctx.runFork(Effect.gen(function* () {
647
652
  yield* (yield* DataStore).associateLatestRunWithSession({
648
653
  chatId,
649
654
  invocationMethod: "mcp"
650
655
  });
651
- })).catch(() => void 0);
656
+ }));
652
657
  let scopedNote = null;
653
658
  if (hasFilter) {
654
659
  const testedFileCount = testModules.length;
@@ -714,7 +719,8 @@ const handleRunTests = (input) => Effect.gen(function* () {
714
719
  cwd: session.cwd,
715
720
  currentSessionId: session.currentSessionId,
716
721
  sessionContext: session.sessionContext,
717
- runPromise: (effect) => Effect.runPromise(Effect.provideContext(effect, services))
722
+ runPromise: (effect) => Effect.runPromise(Effect.provideContext(effect, services)),
723
+ runFork: makeBestEffortFork(services)
718
724
  };
719
725
  return yield* Semaphore.withPermit(runTestsSemaphore, Effect.promise(() => runTestsBody(input, ctx)));
720
726
  });
@@ -735,4 +741,4 @@ const runTestsTool = Tool.make("run_tests", {
735
741
  }).annotate(Tool.Title, "Run tests").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false).annotate(RenderText, (encoded) => formatRunTestsMarkdown(encoded));
736
742
 
737
743
  //#endregion
738
- export { RunTestsInput, RunTestsResult, coerceErrors, composeTagExpression, formatNoMatchMarkdown, formatReportMarkdown, formatRunTestsMarkdown, handleRunTests, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveAnchoredConfigFile, resolveConfigAnchoredRoot, resolveGitCommonDir, resolveVitestNodeEntry, runTestsTool, sanitizeTestArgs, validateProjectRoot, vitestLoader, withStdioCaptured };
744
+ export { RunTestsInput, RunTestsResult, coerceErrors, composeTagExpression, formatNoMatchMarkdown, formatReportMarkdown, formatRunTestsMarkdown, handleRunTests, makeBestEffortFork, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveAnchoredConfigFile, resolveConfigAnchoredRoot, resolveGitCommonDir, resolveVitestNodeEntry, runTestsTool, sanitizeTestArgs, validateProjectRoot, vitestLoader, withStdioCaptured };
package/tools/test.js CHANGED
@@ -81,10 +81,13 @@ const AttachmentDescriptor = Schema.Struct({
81
81
  * per attachment, so a test with many attachments could still flood an
82
82
  * agent's context; `maxBytes` (default 0) is the total byte budget for
83
83
  * every body in one response. Attachments are walked in order and each
84
- * body is charged its recorded `byteSize` (falling back to the stored
85
- * string's length when the row carries none). A body that would take
86
- * the running total past the budget is dropped along with its
87
- * `bodyEncoding`; the descriptor half `contentType`, `path`,
84
+ * body is charged the byte length of the string that will actually be
85
+ * placed in the response NOT its recorded `byteSize`, which is the
86
+ * decoded size and undercounts a base64 body by a quarter (issue #393),
87
+ * and not `String.length`, which counts UTF-16 code units and undercounts
88
+ * multibyte utf-8. A body
89
+ * that would take the running total past the budget is dropped along
90
+ * with its `bodyEncoding`; the descriptor half — `contentType`, `path`,
88
91
  * `byteSize` — always survives.
89
92
  */
90
93
  const applyBodyBudget = (rows, maxBytes) => {
@@ -94,7 +97,7 @@ const applyBodyBudget = (rows, maxBytes) => {
94
97
  attachments: row.attachments.map((attachment) => {
95
98
  const { body, bodyEncoding, ...descriptor } = attachment;
96
99
  if (body === void 0) return descriptor;
97
- const cost = attachment.byteSize ?? body.length;
100
+ const cost = Buffer.byteLength(body, "utf-8");
98
101
  if (spent + cost > maxBytes) return descriptor;
99
102
  spent += cost;
100
103
  return {
package/version.js CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @public
9
9
  */
10
- const CURRENT_MCP_VERSION = "4.0.2";
10
+ const CURRENT_MCP_VERSION = "4.0.3";
11
11
 
12
12
  //#endregion
13
13
  export { CURRENT_MCP_VERSION };