@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 +15 -18
- package/index.d.ts +2 -2
- package/package.json +3 -3
- package/tools/run-tests.js +37 -31
- package/tools/test.js +8 -5
- package/version.js +1 -1
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
49
|
-
"@vitest-agent/sdk": "5.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": {
|
package/tools/run-tests.js
CHANGED
|
@@ -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
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
|
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.
|
|
256
|
-
*
|
|
257
|
-
*
|
|
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
|
-
|
|
265
|
-
|
|
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.
|
|
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
|
-
}))
|
|
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
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* `
|
|
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 =
|
|
100
|
+
const cost = Buffer.byteLength(body, "utf-8");
|
|
98
101
|
if (spent + cost > maxBytes) return descriptor;
|
|
99
102
|
spent += cost;
|
|
100
103
|
return {
|