@vitest-agent/mcp 1.0.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/bin/vitest-agent-mcp.js +93 -0
  4. package/context.js +72 -0
  5. package/index.d.ts +1577 -0
  6. package/index.js +19 -0
  7. package/layers/McpLive.js +30 -0
  8. package/middleware/idempotency.js +128 -0
  9. package/package.json +58 -0
  10. package/prompts/explain-failure.js +27 -0
  11. package/prompts/index.js +89 -0
  12. package/prompts/regression-since-pass.js +28 -0
  13. package/prompts/tdd-resume.js +28 -0
  14. package/prompts/triage.js +24 -0
  15. package/prompts/why-flaky.js +30 -0
  16. package/prompts/wrapup.js +19 -0
  17. package/resources/index.js +155 -0
  18. package/resources/indexes.js +77 -0
  19. package/resources/manifest-schema.js +46 -0
  20. package/resources/paths.js +20 -0
  21. package/resources/patterns.js +22 -0
  22. package/resources/upstream-docs.js +22 -0
  23. package/router.js +74 -0
  24. package/server.js +838 -0
  25. package/tools/_tdd-error-envelope.js +98 -0
  26. package/tools/acceptance-metrics.js +75 -0
  27. package/tools/cache-health.js +83 -0
  28. package/tools/commit-changes.js +64 -0
  29. package/tools/configure.js +107 -0
  30. package/tools/coverage.js +76 -0
  31. package/tools/errors.js +151 -0
  32. package/tools/failure-signature-get.js +73 -0
  33. package/tools/file-coverage.js +106 -0
  34. package/tools/help.js +146 -0
  35. package/tools/history.js +121 -0
  36. package/tools/hypothesis.js +127 -0
  37. package/tools/inventory.js +377 -0
  38. package/tools/note.js +208 -0
  39. package/tools/overview.js +92 -0
  40. package/tools/ping.js +22 -0
  41. package/tools/register-agent.js +135 -0
  42. package/tools/run-tests.js +359 -0
  43. package/tools/settings-list.js +48 -0
  44. package/tools/status.js +74 -0
  45. package/tools/tdd-artifact.js +101 -0
  46. package/tools/tdd-behavior.js +177 -0
  47. package/tools/tdd-goal.js +147 -0
  48. package/tools/tdd-phase-transition-request.js +212 -0
  49. package/tools/tdd-task.js +278 -0
  50. package/tools/test.js +281 -0
  51. package/tools/trends.js +112 -0
  52. package/tools/triage-brief.js +42 -0
  53. package/tools/turn-search.js +60 -0
  54. package/tools/wrapup-prompt.js +49 -0
  55. package/tsdoc-metadata.json +11 -0
  56. package/utils/effect-to-zod.js +81 -0
@@ -0,0 +1,135 @@
1
+ import { publicProcedure } from "../context.js";
2
+ import { DataReader, DataStore, deriveIdempotencyKey } from "@vitest-agent/sdk";
3
+ import { Effect, Option, Schema } from "effect";
4
+
5
+ //#region src/tools/register-agent.ts
6
+ /**
7
+ * `register_agent` MCP tool.
8
+ *
9
+ * Idempotently inserts an `agents` row in the per-project store.
10
+ * Cross-client by design: the input takes a generic `hostKind`
11
+ * (default-resolved from `clientInfo.name` at the MCP boundary in a
12
+ * follow-up; the tool itself defaults to `"claude-code"` for now)
13
+ * and the canonical UUID-typed `chatId` / `conversationId` /
14
+ * `parentAgentId` brands.
15
+ *
16
+ * Returns the resolved `agentId` on success or
17
+ * `{ ok: false, error: { code, ...details } }` for the four
18
+ * documented error codes:
19
+ *
20
+ * - `AGENT_ALREADY_REGISTERED` — idempotency hit; carries
21
+ * `existingAgentId` so the caller proceeds with the recovered ID.
22
+ * - `PARENT_AGENT_NOT_FOUND` — `parentAgentId` references an agent
23
+ * not in the named session.
24
+ * - `SESSION_NOT_FOUND` — the integer FK could not be resolved
25
+ * from `chatId` (host has not called the SessionStart equivalent
26
+ * yet).
27
+ * - `INVALID_AGENT_TYPE_PREFIX` — `agentType` does not start with
28
+ * `${hostKind}-`.
29
+ *
30
+ * The success-with-info `IdempotencyHit` from the SDK collapses into
31
+ * `AGENT_ALREADY_REGISTERED` here so MCP clients see one shape per
32
+ * outcome.
33
+ */
34
+ const RegisterAgentInput = Schema.Struct({
35
+ chatId: Schema.String,
36
+ conversationId: Schema.optional(Schema.String),
37
+ hostKind: Schema.optional(Schema.String),
38
+ agentType: Schema.String,
39
+ parentAgentId: Schema.optional(Schema.String),
40
+ clientNonce: Schema.optional(Schema.String),
41
+ startGitBranch: Schema.optional(Schema.String),
42
+ startGitCommitSha: Schema.optional(Schema.String),
43
+ startWorktreeDir: Schema.optional(Schema.String)
44
+ });
45
+ const RegisterAgentSuccess = Schema.Struct({
46
+ ok: Schema.Literal(true).annotations({ description: "Discriminant — `true` when the agent row was inserted (or an existing one recovered)." }),
47
+ agentId: Schema.String.annotations({
48
+ title: "agents.agent_id",
49
+ description: "Canonical UUID for the registered agent — pass to subsequent attribution-bearing calls."
50
+ }),
51
+ conversationId: Schema.NullOr(Schema.String).annotations({ description: "Conversation UUID from the host's transcript when one was supplied; `null` otherwise." }),
52
+ idempotencyKey: Schema.String.annotations({ description: "26-char base32 SHA-256 of (agentType, parentAgentId|sentinel, clientNonce). Stable across retries with identical input." })
53
+ }).annotations({ identifier: "RegisterAgentSuccess" });
54
+ const RegisterAgentFailure = Schema.Struct({
55
+ ok: Schema.Literal(false).annotations({ description: "Discriminant — `false` when registration was refused." }),
56
+ error: Schema.Struct({
57
+ code: Schema.Literal("AGENT_ALREADY_REGISTERED", "PARENT_AGENT_NOT_FOUND", "SESSION_NOT_FOUND", "INVALID_AGENT_TYPE_PREFIX").annotations({ description: "Refusal reason. AGENT_ALREADY_REGISTERED carries `existingAgentId` so the caller can recover. INVALID_AGENT_TYPE_PREFIX carries `expectedPrefix`." }),
58
+ message: Schema.String.annotations({ description: "Human-readable refusal explanation." }),
59
+ existingAgentId: Schema.optional(Schema.String).annotations({ description: "Present only when `code = AGENT_ALREADY_REGISTERED`. Use this id instead of registering a new one." }),
60
+ expectedPrefix: Schema.optional(Schema.String).annotations({ description: "Present only when `code = INVALID_AGENT_TYPE_PREFIX`. The required `<hostKind>-` prefix." })
61
+ })
62
+ }).annotations({ identifier: "RegisterAgentFailure" });
63
+ const RegisterAgentResult = Schema.Union(RegisterAgentSuccess, RegisterAgentFailure).annotations({
64
+ identifier: "RegisterAgentResult",
65
+ title: "register_agent result",
66
+ description: "Discriminate on `ok`. The four failure codes are documented per their `code` literal."
67
+ });
68
+ const registerAgent = publicProcedure.input(Schema.standardSchemaV1(RegisterAgentInput)).mutation(async ({ ctx, input }) => {
69
+ const expectedPrefix = `${input.hostKind ?? "claude-code"}-`;
70
+ if (!input.agentType.startsWith(expectedPrefix)) return {
71
+ ok: false,
72
+ error: {
73
+ code: "INVALID_AGENT_TYPE_PREFIX",
74
+ message: `agentType "${input.agentType}" must start with "${expectedPrefix}"`,
75
+ expectedPrefix
76
+ }
77
+ };
78
+ const clientNonce = input.clientNonce ?? `${input.chatId}|${input.agentType}|${input.parentAgentId ?? "__ROOT__"}`;
79
+ const idempotencyKey = deriveIdempotencyKey({
80
+ agentType: input.agentType,
81
+ parentAgentId: input.parentAgentId ?? null,
82
+ clientNonce
83
+ });
84
+ return ctx.runtime.runPromise(Effect.gen(function* () {
85
+ const reader = yield* DataReader;
86
+ const store = yield* DataStore;
87
+ const sessionOpt = yield* reader.getSessionByChatId(input.chatId);
88
+ if (Option.isNone(sessionOpt)) return {
89
+ ok: false,
90
+ error: {
91
+ code: "SESSION_NOT_FOUND",
92
+ message: `chat ${input.chatId} has not been registered; the host must call its SessionStart equivalent first`
93
+ }
94
+ };
95
+ const sessionRowId = sessionOpt.value.id;
96
+ const result = yield* store.registerAgent({
97
+ sessionId: sessionRowId,
98
+ agentType: input.agentType,
99
+ parentAgentId: input.parentAgentId ?? null,
100
+ conversationId: input.conversationId ?? null,
101
+ startedAt: Math.floor(Date.now() / 1e3),
102
+ ...input.startGitBranch !== void 0 && { startGitBranch: input.startGitBranch },
103
+ ...input.startGitCommitSha !== void 0 && { startGitCommitSha: input.startGitCommitSha },
104
+ ...input.startWorktreeDir !== void 0 && { startWorktreeDir: input.startWorktreeDir },
105
+ idempotencyKey
106
+ }).pipe(Effect.catchTag("RegistrationConflictError", (e) => Effect.succeed({
107
+ _tag: "Conflict",
108
+ reason: e.reason
109
+ })));
110
+ if ("_tag" in result && result._tag === "Conflict") return {
111
+ ok: false,
112
+ error: {
113
+ code: "PARENT_AGENT_NOT_FOUND",
114
+ message: result.reason
115
+ }
116
+ };
117
+ if ("_tag" in result && result._tag === "IdempotencyHit") return {
118
+ ok: false,
119
+ error: {
120
+ code: "AGENT_ALREADY_REGISTERED",
121
+ message: "agent already registered for (chatId, agentType, parentAgentId, clientNonce); use existingAgentId",
122
+ existingAgentId: result.existingAgentId
123
+ }
124
+ };
125
+ return {
126
+ ok: true,
127
+ agentId: result.agentId,
128
+ conversationId: result.conversationId,
129
+ idempotencyKey: result.idempotencyKey
130
+ };
131
+ }));
132
+ });
133
+
134
+ //#endregion
135
+ export { RegisterAgentResult, registerAgent };
@@ -0,0 +1,359 @@
1
+ import { publicProcedure } from "../context.js";
2
+ import { AgentReport, DataReader, DataStore, buildAgentReport } from "@vitest-agent/sdk";
3
+ import { Effect, ParseResult, Schema } from "effect";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+ import { Writable } from "node:stream";
6
+
7
+ //#region src/tools/run-tests.ts
8
+ const RunTestsOk = Schema.Struct({
9
+ kind: Schema.Literal("ok").annotations({ description: "Discriminant — `true` test run completed (with or without failures)." }),
10
+ project: Schema.optional(Schema.String),
11
+ report: AgentReport.annotations({ description: "Full AgentReport including pass/fail counts and per-module errors." }),
12
+ classifications: Schema.Record({
13
+ key: Schema.String,
14
+ value: Schema.String
15
+ }).annotations({ description: "Per-test classification labels: stable, new-failure, persistent, flaky, recovered." })
16
+ }).annotations({ identifier: "RunTestsOk" });
17
+ const RunTestsTimeout = Schema.Struct({
18
+ kind: Schema.Literal("timeout"),
19
+ timeoutSeconds: Schema.Number
20
+ }).annotations({ identifier: "RunTestsTimeout" });
21
+ const RunTestsError = Schema.Struct({
22
+ kind: Schema.Literal("error"),
23
+ message: Schema.String
24
+ }).annotations({ identifier: "RunTestsError" });
25
+ const TagFilter = Schema.Struct({
26
+ all: Schema.optional(Schema.Array(Schema.String)),
27
+ any: Schema.optional(Schema.Array(Schema.String)),
28
+ none: Schema.optional(Schema.Array(Schema.String))
29
+ }).annotations({
30
+ identifier: "TagFilter",
31
+ description: "All three sub-filters AND together with `project` and `files`. `all` requires every listed tag on the test. `any` requires at least one. `none` excludes any test carrying a listed tag."
32
+ });
33
+ const RunTestsNoMatch = Schema.Struct({
34
+ kind: Schema.Literal("no-match").annotations({ description: "Discriminant — the resolved filter set matched zero test cases. Tests did not run; this is independent of passWithNoTests policy." }),
35
+ filter: Schema.Struct({
36
+ project: Schema.NullOr(Schema.String),
37
+ files: Schema.Array(Schema.String),
38
+ tags: Schema.NullOr(TagFilter),
39
+ resolvedExpression: Schema.NullOr(Schema.String)
40
+ })
41
+ }).annotations({ identifier: "RunTestsNoMatch" });
42
+ const RunTestsResult = Schema.Union(RunTestsOk, RunTestsTimeout, RunTestsError, RunTestsNoMatch).annotations({
43
+ identifier: "RunTestsResult",
44
+ title: "run_tests result",
45
+ description: "Discriminate on `kind`. ok carries the full AgentReport plus per-test classifications; timeout / error are the two failure modes; no-match indicates that the resolved filter set matched zero test cases."
46
+ });
47
+ /**
48
+ * Compose a Vitest tag-expression string from a structured {@link TagFilter}.
49
+ *
50
+ * Returns `null` when every sub-filter is empty/absent. Combines the three
51
+ * sub-filters with ` and `:
52
+ *
53
+ * - `all: ["int", "slow"]` → `"int and slow"`
54
+ * - `any: ["unit", "int"]` → `"(unit or int)"`
55
+ * - `none: ["slow", "flaky"]`→ `"not slow and not flaky"`
56
+ *
57
+ * @internal
58
+ */
59
+ function composeTagExpression(tags) {
60
+ if (!tags) return null;
61
+ const parts = [];
62
+ const all = tags.all ?? [];
63
+ const any = tags.any ?? [];
64
+ const none = tags.none ?? [];
65
+ if (all.length > 0) parts.push(all.join(" and "));
66
+ if (any.length > 0) parts.push(any.length === 1 ? any[0] : `(${any.join(" or ")})`);
67
+ if (none.length > 0) parts.push(none.map((t) => `not ${t}`).join(" and "));
68
+ if (parts.length === 0) return null;
69
+ return parts.join(" and ");
70
+ }
71
+ const FORBIDDEN_CHARS = /[;|&`$(){}[\]<>!#]/;
72
+ const stdoutSinkStorage = new AsyncLocalStorage();
73
+ const stderrSinkStorage = new AsyncLocalStorage();
74
+ let _stdioPatched = false;
75
+ let _originalStdoutWrite;
76
+ let _originalStderrWrite;
77
+ function ensureStdioPatched() {
78
+ if (_stdioPatched) return;
79
+ _stdioPatched = true;
80
+ _originalStdoutWrite = process.stdout.write;
81
+ _originalStderrWrite = process.stderr.write;
82
+ process.stdout.write = function patchedStdoutWrite(...args) {
83
+ const sink = stdoutSinkStorage.getStore();
84
+ if (sink) return sink.write.apply(sink, args);
85
+ return _originalStdoutWrite.apply(this, args);
86
+ };
87
+ process.stderr.write = function patchedStderrWrite(...args) {
88
+ const sink = stderrSinkStorage.getStore();
89
+ if (sink) return sink.write.apply(sink, args);
90
+ return _originalStderrWrite.apply(this, args);
91
+ };
92
+ }
93
+ /**
94
+ * Run `fn` with `process.stdout.write` and `process.stderr.write`
95
+ * diverted to `stream.write` for code executing inside the call's
96
+ * async context. Code in other async contexts (concurrent tRPC
97
+ * procedure handlers, the MCP stdio transport) sees the original
98
+ * writes unchanged.
99
+ *
100
+ * Vitest's own stdout/stderr redirect options only cover Vitest-internal
101
+ * logging. User-registered reporters that call `console.log` directly
102
+ * bypass them; this helper captures those writes into the supplied
103
+ * sink so they don't corrupt the JSON-RPC protocol stream.
104
+ *
105
+ * @internal
106
+ */
107
+ async function withStdioCaptured(stream, fn) {
108
+ ensureStdioPatched();
109
+ return stdoutSinkStorage.run(stream, () => stderrSinkStorage.run(stream, fn));
110
+ }
111
+ function sanitizeTestArgs(args) {
112
+ const result = [];
113
+ for (const arg of args) {
114
+ if (FORBIDDEN_CHARS.test(arg)) throw new Error(`Unsafe argument rejected: ${arg}`);
115
+ result.push(arg);
116
+ }
117
+ return result;
118
+ }
119
+ let _runTestsChain = Promise.resolve();
120
+ function serializeRunTests(fn) {
121
+ const next = _runTestsChain.then(fn, fn);
122
+ _runTestsChain = next.catch(() => void 0);
123
+ return next;
124
+ }
125
+ /**
126
+ * Coerce unknown Vitest unhandled errors into VitestModuleError shape.
127
+ *
128
+ * @internal
129
+ */
130
+ function coerceErrors(errors) {
131
+ return errors.map((e) => {
132
+ if (e && typeof e === "object" && "message" in e) {
133
+ const err = e;
134
+ return {
135
+ message: String(err.message),
136
+ ...err.stacks ? { stacks: err.stacks } : err.stack ? { stacks: [err.stack] } : {}
137
+ };
138
+ }
139
+ return { message: String(e) };
140
+ });
141
+ }
142
+ /**
143
+ * Render the full structured `RunTestsResult` as markdown for the
144
+ * text channel. Discriminates on `kind` then defers to the existing
145
+ * AgentReport rendering for the `ok` case.
146
+ */
147
+ function formatRunTestsMarkdown(data) {
148
+ if (data.kind === "timeout") return `Test run timed out after ${data.timeoutSeconds} seconds.`;
149
+ if (data.kind === "error") return `Test run failed: ${data.message}`;
150
+ if (data.kind === "no-match") return formatNoMatchMarkdown(data.filter);
151
+ const classMap = new Map(Object.entries(data.classifications));
152
+ return formatReportMarkdown(data.report, classMap);
153
+ }
154
+ /**
155
+ * Render the `no-match` filter context plus a remediation pointer aimed at
156
+ * tag introspection. Pure helper; called by {@link formatRunTestsMarkdown}.
157
+ *
158
+ * @internal
159
+ */
160
+ function formatNoMatchMarkdown(filter) {
161
+ const lines = ["## No tests matched the filter", ""];
162
+ const parts = [];
163
+ if (filter.project !== null) parts.push(`project: \`${filter.project}\``);
164
+ if (filter.files.length > 0) parts.push(`files: ${filter.files.map((f) => `\`${f}\``).join(", ")}`);
165
+ if (filter.tags !== null) {
166
+ const t = filter.tags;
167
+ if (t.all && t.all.length > 0) parts.push(`tags.all: ${t.all.map((s) => `\`${s}\``).join(", ")}`);
168
+ if (t.any && t.any.length > 0) parts.push(`tags.any: ${t.any.map((s) => `\`${s}\``).join(", ")}`);
169
+ if (t.none && t.none.length > 0) parts.push(`tags.none: ${t.none.map((s) => `\`${s}\``).join(", ")}`);
170
+ }
171
+ if (filter.resolvedExpression !== null) parts.push(`resolved expression: \`${filter.resolvedExpression}\``);
172
+ if (parts.length === 0) lines.push("- (no filter recorded)");
173
+ else for (const p of parts) lines.push(`- ${p}`);
174
+ lines.push("");
175
+ lines.push("### Next steps");
176
+ if (filter.tags !== null) {
177
+ lines.push("- Confirm the tag exists: `inventory({ kind: \"tag\" })`");
178
+ lines.push("- List tests for a specific tag: `test({ action: \"for_tag\", tag: \"<name>\" })`");
179
+ }
180
+ if (filter.files.length > 0) lines.push("- Verify file paths exist or list tests in a file with `test({ action: \"for_file\", filePath })`");
181
+ if (filter.project !== null) lines.push("- Verify the project name with `inventory({ kind: \"project\" })`");
182
+ return lines.join("\n");
183
+ }
184
+ const RunTestsAsMarkdown = Schema.transformOrFail(RunTestsResult, Schema.String, {
185
+ strict: true,
186
+ decode: (data) => ParseResult.succeed(formatRunTestsMarkdown(data)),
187
+ encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "RunTestsAsMarkdown is one-way."))
188
+ });
189
+ /**
190
+ * Format an AgentReport as concise markdown suitable for MCP tool output.
191
+ *
192
+ * Classifications map test fullName to labels like "new-failure",
193
+ * "persistent", "flaky", "recovered", "stable". Populated from DB
194
+ * after the reporter writes history.
195
+ *
196
+ * @internal
197
+ */
198
+ function formatReportMarkdown(report, classifications) {
199
+ const lines = [];
200
+ const { summary } = report;
201
+ const collectionFailedFiles = report.failed.filter((m) => m.errors !== void 0 && m.errors.length > 0 && !m.tests.some((t) => t.state === "failed")).map((m) => m.file);
202
+ const hasCollectionFailures = collectionFailedFiles.length > 0;
203
+ const isFailing = summary.failed > 0 || report.unhandledErrors.length > 0 || hasCollectionFailures;
204
+ const status = isFailing ? "❌" : "✅";
205
+ const headlineParts = [];
206
+ if (summary.failed > 0) headlineParts.push(`${summary.failed} failed`);
207
+ if (hasCollectionFailures) headlineParts.push(`${collectionFailedFiles.length} failed to load`);
208
+ headlineParts.push(`${summary.passed} passed`);
209
+ if (summary.skipped > 0) headlineParts.push(`${summary.skipped} skipped`);
210
+ lines.push(`## ${status} Vitest -- ${headlineParts.join(", ")} (${summary.duration}ms)`);
211
+ if (report.project) lines.push(`\nProject: ${report.project}`);
212
+ for (const mod of report.failed) {
213
+ lines.push(`\n### \u274C \`${mod.file}\``);
214
+ if (mod.errors) for (const err of mod.errors) {
215
+ lines.push(`\n- \u274C **Module failed to load**: ${err.message}`);
216
+ if (err.stack) lines.push(`\n \`\`\`\n ${err.stack}\n \`\`\``);
217
+ }
218
+ for (const test of mod.tests) {
219
+ if (test.state !== "failed") continue;
220
+ const badge = classifications?.get(test.fullName);
221
+ const label = badge ? ` [${badge}]` : "";
222
+ lines.push(`\n- \u274C **${test.fullName}**${label}`);
223
+ if (test.errors) for (const err of test.errors) {
224
+ lines.push(` ${err.message}`);
225
+ if (err.diff) {
226
+ const diff = err.diff.length > 1e3 ? `${err.diff.slice(0, 1e3)}\n... (truncated, ${err.diff.length} chars total)` : err.diff;
227
+ lines.push(`\n \`\`\`diff\n ${diff}\n \`\`\``);
228
+ }
229
+ }
230
+ }
231
+ }
232
+ if (report.unhandledErrors.length > 0) {
233
+ lines.push("\n### Unhandled Errors");
234
+ for (const err of report.unhandledErrors) {
235
+ lines.push(`\n- ${err.message}`);
236
+ if (err.stack) lines.push(` \`\`\`\n ${err.stack}\n \`\`\``);
237
+ }
238
+ }
239
+ if (isFailing) {
240
+ const newFailures = classifications ? [...classifications.values()].filter((c) => c === "new-failure").length : 0;
241
+ const persistent = classifications ? [...classifications.values()].filter((c) => c === "persistent").length : 0;
242
+ const flaky = classifications ? [...classifications.values()].filter((c) => c === "flaky").length : 0;
243
+ lines.push("\n### Next steps\n");
244
+ if (newFailures > 0) lines.push(`- ${newFailures} new failure${newFailures > 1 ? "s" : ""} since last run`);
245
+ if (persistent > 0) lines.push(`- ${persistent} persistent failure${persistent > 1 ? "s" : ""} (pre-existing)`);
246
+ if (flaky > 0) lines.push(`- ${flaky} flaky test${flaky > 1 ? "s" : ""} -- consider retrying`);
247
+ lines.push("- Use test_errors for detailed error analysis");
248
+ lines.push("- Use test_history to check failure patterns");
249
+ if (report.failedFiles.length > 0) lines.push(`- Re-run failed: run_tests({ files: ${JSON.stringify(report.failedFiles)} })`);
250
+ }
251
+ return lines.join("\n");
252
+ }
253
+ const runTests = publicProcedure.input(Schema.standardSchemaV1(Schema.Struct({
254
+ files: Schema.optional(Schema.Array(Schema.String)),
255
+ project: Schema.optional(Schema.String),
256
+ tags: Schema.optional(TagFilter),
257
+ passWithNoTests: Schema.optional(Schema.Boolean),
258
+ timeout: Schema.optional(Schema.Number),
259
+ _sessionContext: Schema.optional(Schema.Struct({
260
+ chatId: Schema.String,
261
+ conversationId: Schema.String,
262
+ mainAgentId: Schema.String
263
+ }))
264
+ }))).mutation(({ ctx, input }) => serializeRunTests(async () => {
265
+ const files = input.files ? sanitizeTestArgs(input.files) : [];
266
+ const project = input.project ? sanitizeTestArgs([input.project])[0] : void 0;
267
+ const tagsInput = input.tags;
268
+ if (tagsInput) {
269
+ if (tagsInput.all) sanitizeTestArgs(tagsInput.all);
270
+ if (tagsInput.any) sanitizeTestArgs(tagsInput.any);
271
+ if (tagsInput.none) sanitizeTestArgs(tagsInput.none);
272
+ }
273
+ const resolvedExpression = composeTagExpression(tagsInput ?? null);
274
+ const hasFilter = files.length > 0 || project !== void 0 || resolvedExpression !== null;
275
+ const timeoutMs = (input.timeout ?? 120) * 1e3;
276
+ const recovered = input._sessionContext ?? null ?? ctx.sessionContext.get();
277
+ if (recovered !== null) {
278
+ process.env.VITEST_AGENT_CHAT_ID = recovered.chatId;
279
+ process.env.VITEST_AGENT_CONVERSATION_ID = recovered.conversationId;
280
+ process.env.VITEST_AGENT_AGENT_ID = recovered.mainAgentId;
281
+ }
282
+ const nullStream = new Writable({ write(_chunk, _encoding, cb) {
283
+ cb();
284
+ } });
285
+ const { createVitest } = await import("vitest/node");
286
+ let vitest;
287
+ try {
288
+ vitest = await createVitest("test", {
289
+ root: ctx.cwd,
290
+ run: true,
291
+ ...project ? { project } : {},
292
+ ...resolvedExpression !== null ? { tagsFilter: [resolvedExpression] } : {},
293
+ ...input.passWithNoTests !== void 0 ? { passWithNoTests: input.passWithNoTests } : {}
294
+ }, {}, {
295
+ stdout: nullStream,
296
+ stderr: nullStream
297
+ });
298
+ const localVitest = vitest;
299
+ let timeoutHandle;
300
+ const result = await withStdioCaptured(nullStream, () => Promise.race([localVitest.start(files.length > 0 ? files : void 0), new Promise((_, reject) => {
301
+ timeoutHandle = setTimeout(() => reject(/* @__PURE__ */ new Error("VITEST_TIMEOUT")), timeoutMs);
302
+ })]).finally(() => {
303
+ if (timeoutHandle !== void 0) clearTimeout(timeoutHandle);
304
+ }));
305
+ const testModules = result.testModules;
306
+ const unhandledErrors = coerceErrors(result.unhandledErrors);
307
+ if (hasFilter && result.testModules.length === 0 && unhandledErrors.length === 0) return {
308
+ kind: "no-match",
309
+ filter: {
310
+ project: project ?? null,
311
+ files,
312
+ tags: tagsInput ?? null,
313
+ resolvedExpression
314
+ }
315
+ };
316
+ const report = buildAgentReport(testModules, unhandledErrors, unhandledErrors.length > 0 || result.testModules.some((m) => m.state() === "failed") ? "failed" : "passed", { omitPassingTests: true });
317
+ let classifications;
318
+ try {
319
+ classifications = await ctx.runtime.runPromise(Effect.gen(function* () {
320
+ const reader = yield* DataReader;
321
+ const projects = project ? [project] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => r.project)));
322
+ const entries = [];
323
+ for (const p of projects) {
324
+ const tests = yield* reader.listTests(p, {});
325
+ for (const t of tests) if (t.classification != null) entries.push([t.fullName, t.classification]);
326
+ }
327
+ return new Map(entries);
328
+ }));
329
+ } catch {}
330
+ const chatId = ctx.currentSessionId.get();
331
+ if (chatId !== null) ctx.runtime.runPromise(Effect.gen(function* () {
332
+ yield* (yield* DataStore).associateLatestRunWithSession({
333
+ chatId,
334
+ invocationMethod: "mcp"
335
+ });
336
+ })).catch(() => void 0);
337
+ return {
338
+ kind: "ok",
339
+ ...project !== void 0 && { project },
340
+ report,
341
+ classifications: classifications ? Object.fromEntries(classifications) : {}
342
+ };
343
+ } catch (err) {
344
+ if (err instanceof Error && err.message === "VITEST_TIMEOUT") return {
345
+ kind: "timeout",
346
+ timeoutSeconds: input.timeout ?? 120
347
+ };
348
+ return {
349
+ kind: "error",
350
+ message: err instanceof Error ? err.message : String(err)
351
+ };
352
+ } finally {
353
+ await vitest?.close();
354
+ nullStream.destroy();
355
+ }
356
+ }));
357
+
358
+ //#endregion
359
+ export { RunTestsAsMarkdown, RunTestsResult, runTests };
@@ -0,0 +1,48 @@
1
+ import { publicProcedure } from "../context.js";
2
+ import { DataReader } from "@vitest-agent/sdk";
3
+ import { Effect, ParseResult, Schema } from "effect";
4
+
5
+ //#region src/tools/settings-list.ts
6
+ /**
7
+ * `settings_list` MCP tool — Schema-driven implementation.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ const SettingsRow = Schema.Struct({
12
+ hash: Schema.String.annotations({ description: "Stable SHA-1 of the captured Vitest settings; FK target on test_runs." }),
13
+ capturedAt: Schema.String.annotations({ description: "ISO-8601 timestamp the settings row was first written." })
14
+ }).annotations({ identifier: "SettingsListRow" });
15
+ const SettingsListResult = Schema.Struct({
16
+ count: Schema.Number,
17
+ settings: Schema.Array(SettingsRow).annotations({ description: "Distinct captured settings hashes the reporter has written, newest first." })
18
+ }).annotations({
19
+ identifier: "SettingsListResult",
20
+ title: "settings_list result",
21
+ description: "Roster of distinct Vitest settings hashes the reporter has captured."
22
+ });
23
+ const formatSettingsListMarkdown = (data) => {
24
+ if (data.settings.length === 0) return "No settings found. Run tests first.";
25
+ const lines = [
26
+ "## Settings",
27
+ "",
28
+ "| Hash | Timestamp |",
29
+ "| --- | --- |"
30
+ ];
31
+ for (const s of data.settings) lines.push(`| ${s.hash} | ${s.capturedAt} |`);
32
+ return lines.join("\n");
33
+ };
34
+ const SettingsListAsMarkdown = Schema.transformOrFail(SettingsListResult, Schema.String, {
35
+ strict: true,
36
+ decode: (data) => ParseResult.succeed(formatSettingsListMarkdown(data)),
37
+ encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "SettingsListAsMarkdown is one-way."))
38
+ });
39
+ const settingsList = publicProcedure.input(Schema.standardSchemaV1(Schema.Struct({}))).query(async ({ ctx }) => ctx.runtime.runPromise(Effect.gen(function* () {
40
+ const settings = yield* (yield* DataReader).listSettings();
41
+ return {
42
+ count: settings.length,
43
+ settings
44
+ };
45
+ })));
46
+
47
+ //#endregion
48
+ export { SettingsListAsMarkdown, SettingsListResult, settingsList };
@@ -0,0 +1,74 @@
1
+ import { publicProcedure } from "../context.js";
2
+ import { CacheManifestEntry, DataReader } from "@vitest-agent/sdk";
3
+ import { Effect, Option, ParseResult, Schema } from "effect";
4
+
5
+ //#region src/tools/status.ts
6
+ /**
7
+ * `test_status` MCP tool — Schema-driven implementation.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ const StatusAvailable = Schema.Struct({
12
+ dataAvailable: Schema.Literal(true).annotations({ description: "Discriminant — `true` when at least one project entry exists in the manifest." }),
13
+ manifestUpdatedAt: Schema.String,
14
+ projectFilter: Schema.optional(Schema.String).annotations({ description: "Echo of the optional `project` filter." }),
15
+ entries: Schema.Array(CacheManifestEntry).annotations({ description: "Per-project last-run summary rows. Filtered by `projectFilter` when set." })
16
+ }).annotations({ identifier: "TestStatusAvailable" });
17
+ const StatusAbsent = Schema.Struct({
18
+ dataAvailable: Schema.Literal(false).annotations({ description: "Discriminant — `false` when no manifest exists or the project filter matched nothing." }),
19
+ projectFilter: Schema.optional(Schema.String),
20
+ reason: Schema.Literal("no_manifest", "project_filter_empty")
21
+ }).annotations({ identifier: "TestStatusAbsent" });
22
+ const TestStatusResult = Schema.Union(StatusAvailable, StatusAbsent).annotations({
23
+ identifier: "TestStatusResult",
24
+ title: "test_status result",
25
+ description: "Per-project last-run summary. Discriminate on `dataAvailable` for cold-start handling."
26
+ });
27
+ const iconForResult = (r) => {
28
+ if (r === "passed") return "✅";
29
+ if (r === "failed") return "❌";
30
+ if (r === "interrupted") return "⚠️";
31
+ return "⬜";
32
+ };
33
+ const formatTestStatusMarkdown = (data) => {
34
+ if (!data.dataAvailable) {
35
+ if (data.reason === "project_filter_empty") return `No test data found for project \`${data.projectFilter ?? "(unknown)"}\`. Run tests first.`;
36
+ return "No test data available. Run tests first.";
37
+ }
38
+ const lines = ["# Test Status", ""];
39
+ for (const entry of data.entries) {
40
+ const lastRun = entry.lastRun ? new Date(entry.lastRun).toLocaleString() : "never";
41
+ lines.push(`- ${iconForResult(entry.lastResult)} **${entry.project}** — last run: ${lastRun}, result: ${entry.lastResult ?? "unknown"}`);
42
+ }
43
+ lines.push("", `_Cache updated: ${data.manifestUpdatedAt}_`);
44
+ return lines.join("\n");
45
+ };
46
+ const TestStatusAsMarkdown = Schema.transformOrFail(TestStatusResult, Schema.String, {
47
+ strict: true,
48
+ decode: (data) => ParseResult.succeed(formatTestStatusMarkdown(data)),
49
+ encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "TestStatusAsMarkdown is one-way."))
50
+ });
51
+ const testStatus = publicProcedure.input(Schema.standardSchemaV1(Schema.Struct({ project: Schema.optional(Schema.String) }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
52
+ const manifestOpt = yield* (yield* DataReader).getManifest();
53
+ if (Option.isNone(manifestOpt)) return {
54
+ dataAvailable: false,
55
+ reason: "no_manifest",
56
+ ...input.project !== void 0 && { projectFilter: input.project }
57
+ };
58
+ const manifest = manifestOpt.value;
59
+ const entries = input.project === void 0 ? manifest.projects : manifest.projects.filter((e) => e.project === input.project);
60
+ if (entries.length === 0) return {
61
+ dataAvailable: false,
62
+ reason: "project_filter_empty",
63
+ ...input.project !== void 0 && { projectFilter: input.project }
64
+ };
65
+ return {
66
+ dataAvailable: true,
67
+ manifestUpdatedAt: manifest.updatedAt,
68
+ ...input.project !== void 0 && { projectFilter: input.project },
69
+ entries
70
+ };
71
+ })));
72
+
73
+ //#endregion
74
+ export { TestStatusAsMarkdown, TestStatusResult, testStatus };