@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,278 @@
1
+ import { idempotentProcedure } from "../middleware/idempotency.js";
2
+ import { DataReader, DataStore, GoalDetail } from "@vitest-agent/sdk";
3
+ import { Effect, Match, Option, ParseResult, Schema } from "effect";
4
+
5
+ //#region src/tools/tdd-task.ts
6
+ /**
7
+ * Consolidated `tdd_task` MCP tool — Schema-driven implementation.
8
+ *
9
+ * `start` and `end` mutate; `get` and `resume` read. Every action
10
+ * now returns a structured payload — `get` carries the full nested
11
+ * `TddTaskDetail` tree plus the `currentPhase` lookup, and `resume`
12
+ * carries a compact summary discriminated by `phaseAvailable`. The
13
+ * boundary in server.ts uses `formatTddTaskMarkdown` to render
14
+ * `get` / `resume` text.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+ const TddPhaseRow = Schema.Struct({
19
+ id: Schema.Number,
20
+ behaviorId: Schema.NullOr(Schema.Number),
21
+ phase: Schema.String,
22
+ startedAt: Schema.String,
23
+ endedAt: Schema.NullOr(Schema.String),
24
+ transitionReason: Schema.NullOr(Schema.String)
25
+ }).annotations({ identifier: "TddTaskPhaseRow" });
26
+ const TddArtifactDetailRow = Schema.Struct({
27
+ id: Schema.Number,
28
+ phaseId: Schema.Number,
29
+ artifactKind: Schema.String,
30
+ testCaseId: Schema.NullOr(Schema.Number),
31
+ testRunId: Schema.NullOr(Schema.Number),
32
+ recordedAt: Schema.String
33
+ }).annotations({ identifier: "TddTaskArtifactRow" });
34
+ const TddTaskDetailSchema = Schema.Struct({
35
+ tddTaskId: Schema.Number,
36
+ sessionId: Schema.Number,
37
+ goal: Schema.String,
38
+ startedAt: Schema.String,
39
+ endedAt: Schema.NullOr(Schema.String),
40
+ outcome: Schema.NullOr(Schema.String),
41
+ runId: Schema.NullOr(Schema.String),
42
+ goals: Schema.Array(GoalDetail),
43
+ phases: Schema.Array(TddPhaseRow),
44
+ artifacts: Schema.Array(TddArtifactDetailRow)
45
+ }).annotations({ identifier: "TddTaskDetailSchema" });
46
+ const CurrentPhaseLookup = Schema.Struct({
47
+ id: Schema.Number,
48
+ phase: Schema.String,
49
+ startedAt: Schema.String,
50
+ behaviorId: Schema.NullOr(Schema.Number)
51
+ }).annotations({ identifier: "TddTaskCurrentPhaseLookup" });
52
+ const TddTaskStartOk = Schema.Struct({
53
+ action: Schema.Literal("start"),
54
+ tddTaskId: Schema.Number,
55
+ goal: Schema.String,
56
+ runId: Schema.optional(Schema.String)
57
+ }).annotations({ identifier: "TddTaskStartOk" });
58
+ const TddTaskEndOk = Schema.Struct({
59
+ action: Schema.Literal("end"),
60
+ tddTaskId: Schema.Number,
61
+ outcome: Schema.Literal("succeeded", "blocked", "abandoned")
62
+ }).annotations({ identifier: "TddTaskEndOk" });
63
+ const TddTaskGetFound = Schema.Struct({
64
+ action: Schema.Literal("get"),
65
+ found: Schema.Literal(true),
66
+ task: TddTaskDetailSchema,
67
+ currentPhase: Schema.NullOr(CurrentPhaseLookup)
68
+ }).annotations({ identifier: "TddTaskGetFound" });
69
+ const TddTaskGetMissing = Schema.Struct({
70
+ action: Schema.Literal("get"),
71
+ found: Schema.Literal(false),
72
+ tddTaskId: Schema.Number
73
+ }).annotations({ identifier: "TddTaskGetMissing" });
74
+ const TddTaskResumeFound = Schema.Struct({
75
+ action: Schema.Literal("resume"),
76
+ found: Schema.Literal(true),
77
+ tddTaskId: Schema.Number,
78
+ goal: Schema.String,
79
+ status: Schema.String,
80
+ currentPhase: Schema.NullOr(CurrentPhaseLookup),
81
+ phasesRecorded: Schema.Number,
82
+ artifactsRecorded: Schema.Number
83
+ }).annotations({ identifier: "TddTaskResumeFound" });
84
+ const TddTaskResumeMissing = Schema.Struct({
85
+ action: Schema.Literal("resume"),
86
+ found: Schema.Literal(false),
87
+ tddTaskId: Schema.Number
88
+ }).annotations({ identifier: "TddTaskResumeMissing" });
89
+ const TddTaskResult = Schema.Union(TddTaskStartOk, TddTaskEndOk, TddTaskGetFound, TddTaskGetMissing, TddTaskResumeFound, TddTaskResumeMissing).annotations({
90
+ identifier: "TddTaskResult",
91
+ title: "tdd_task result",
92
+ description: "Discriminate on `action`. `get` and `resume` further discriminate on `found`. `get` carries the full nested task tree."
93
+ });
94
+ const formatTddTaskMarkdown = (data) => {
95
+ if (data.action === "start" || data.action === "end") return JSON.stringify(data, null, 2);
96
+ if (data.action === "get") {
97
+ if (!data.found) return `No TDD task with tddTaskId=${data.tddTaskId}.`;
98
+ const s = data.task;
99
+ const currentPhaseLine = data.currentPhase === null ? "- current phase: (none — no open phase)" : `- current phase: ${data.currentPhase.phase} [phaseId=${data.currentPhase.id}]${data.currentPhase.behaviorId !== null ? ` behaviorId=${data.currentPhase.behaviorId}` : ""}`;
100
+ const lines = [
101
+ `# TDD Task ${s.tddTaskId}`,
102
+ "",
103
+ `- goal: ${s.goal}`,
104
+ `- run_id: ${s.runId ?? "(none — run_id not recorded)"}`,
105
+ `- sessionId: ${s.sessionId}`,
106
+ `- started: ${s.startedAt}`,
107
+ `- ended: ${s.endedAt ?? "still open"}`,
108
+ `- outcome: ${s.outcome ?? "pending"}`,
109
+ currentPhaseLine
110
+ ];
111
+ if (s.phases.length > 0) {
112
+ lines.push("", "## Phases", "");
113
+ for (const p of s.phases) {
114
+ const duration = p.endedAt ? ` -> ${p.endedAt}` : " (current)";
115
+ lines.push(`- **${p.phase}** [id=${p.id}] ${p.startedAt}${duration}`);
116
+ if (p.transitionReason !== null) lines.push(` - reason: ${p.transitionReason}`);
117
+ }
118
+ }
119
+ if (s.artifacts.length > 0) {
120
+ lines.push("", "## Artifacts", "");
121
+ for (const a of s.artifacts) lines.push(`- **${a.artifactKind}** [id=${a.id}, phase=${a.phaseId}] at=${a.recordedAt}${a.testRunId !== null ? ` run=${a.testRunId}` : ""}`);
122
+ }
123
+ if (s.goals.length > 0) {
124
+ lines.push("", "## Goals and Behaviors", "");
125
+ for (const g of s.goals) {
126
+ lines.push(`### Goal ${g.ordinal + 1}: ${g.goal} [${g.status}]`);
127
+ if (g.behaviors.length > 0) {
128
+ lines.push("");
129
+ for (const b of g.behaviors) lines.push(`- **${b.behavior}** [${b.status}]`);
130
+ }
131
+ lines.push("");
132
+ }
133
+ }
134
+ return lines.join("\n");
135
+ }
136
+ if (!data.found) return `No TDD task with tddTaskId=${data.tddTaskId}.`;
137
+ const lines = [
138
+ `# TDD task #${data.tddTaskId}: ${data.goal}`,
139
+ "",
140
+ `**Status:** ${data.status}`
141
+ ];
142
+ if (data.currentPhase !== null) lines.push(`**Current phase:** ${data.currentPhase.phase} (started ${data.currentPhase.startedAt})`);
143
+ else lines.push("**Current phase:** none (TDD cycle not yet entered)");
144
+ lines.push("", `**Phases recorded:** ${data.phasesRecorded}`);
145
+ if (data.artifactsRecorded > 0) lines.push(`**Artifacts:** ${data.artifactsRecorded}`);
146
+ lines.push("", `Use \`tdd_task({ action: "get", tddTaskId: ${data.tddTaskId} })\` for the full detail tree, or call \`tdd_phase_transition_request\` to advance.`);
147
+ return lines.join("\n");
148
+ };
149
+ const TddTaskAsMarkdown = Schema.transformOrFail(TddTaskResult, Schema.String, {
150
+ strict: true,
151
+ decode: (data) => ParseResult.succeed(formatTddTaskMarkdown(data)),
152
+ encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "TddTaskAsMarkdown is one-way."))
153
+ });
154
+ const StartVariant = Schema.Struct({
155
+ action: Schema.Literal("start"),
156
+ goal: Schema.String,
157
+ sessionId: Schema.optional(Schema.Number),
158
+ chatId: Schema.optional(Schema.String),
159
+ parentTddTaskId: Schema.optional(Schema.Number),
160
+ startedAt: Schema.optional(Schema.String),
161
+ runId: Schema.optional(Schema.String)
162
+ });
163
+ const EndVariant = Schema.Struct({
164
+ action: Schema.Literal("end"),
165
+ tddTaskId: Schema.Number,
166
+ outcome: Schema.Literal("succeeded", "blocked", "abandoned"),
167
+ summaryNoteId: Schema.optional(Schema.Number)
168
+ });
169
+ const GetVariant = Schema.Struct({
170
+ action: Schema.Literal("get"),
171
+ tddTaskId: Schema.Number
172
+ });
173
+ const ResumeVariant = Schema.Struct({
174
+ action: Schema.Literal("resume"),
175
+ tddTaskId: Schema.Number
176
+ });
177
+ const TddTaskInput = Schema.Union(StartVariant, EndVariant, GetVariant, ResumeVariant);
178
+ const tddTask = idempotentProcedure.input(Schema.standardSchemaV1(TddTaskInput)).mutation(async ({ ctx, input }) => {
179
+ return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
180
+ start: (variant) => Effect.gen(function* () {
181
+ const reader = yield* DataReader;
182
+ const store = yield* DataStore;
183
+ let sessionId;
184
+ if (variant.sessionId !== void 0) sessionId = variant.sessionId;
185
+ else if (variant.chatId !== void 0) {
186
+ const opt = yield* reader.getSessionByChatId(variant.chatId);
187
+ if (Option.isNone(opt)) return yield* Effect.fail(/* @__PURE__ */ new Error(`Unknown chatId: ${variant.chatId}. Run record session-start first.`));
188
+ sessionId = opt.value.id;
189
+ } else return yield* Effect.fail(/* @__PURE__ */ new Error("tdd_task action=start: provide sessionId or chatId"));
190
+ if (variant.runId !== void 0 && variant.runId.trim().length === 0) return yield* Effect.fail(/* @__PURE__ */ new Error("tdd_task action=start: runId must not be blank"));
191
+ return {
192
+ action: "start",
193
+ tddTaskId: yield* store.writeTddTask({
194
+ sessionId,
195
+ goal: variant.goal,
196
+ startedAt: variant.startedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
197
+ ...variant.runId !== void 0 && { runId: variant.runId },
198
+ ...variant.parentTddTaskId !== void 0 && { parentTddTaskId: variant.parentTddTaskId }
199
+ }),
200
+ goal: variant.goal,
201
+ ...variant.runId !== void 0 && { runId: variant.runId }
202
+ };
203
+ }),
204
+ end: (variant) => Effect.gen(function* () {
205
+ yield* (yield* DataStore).endTddTask({
206
+ id: variant.tddTaskId,
207
+ outcome: variant.outcome,
208
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
209
+ ...variant.summaryNoteId !== void 0 && { summaryNoteId: variant.summaryNoteId }
210
+ });
211
+ return {
212
+ action: "end",
213
+ tddTaskId: variant.tddTaskId,
214
+ outcome: variant.outcome
215
+ };
216
+ }),
217
+ get: (variant) => Effect.gen(function* () {
218
+ const reader = yield* DataReader;
219
+ const opt = yield* reader.getTddTaskById(variant.tddTaskId);
220
+ if (Option.isNone(opt)) return {
221
+ action: "get",
222
+ found: false,
223
+ tddTaskId: variant.tddTaskId
224
+ };
225
+ const currentOpt = yield* reader.getCurrentTddPhase(variant.tddTaskId);
226
+ const { id, ...rest } = opt.value;
227
+ return {
228
+ action: "get",
229
+ found: true,
230
+ task: {
231
+ tddTaskId: id,
232
+ ...rest
233
+ },
234
+ currentPhase: Option.match(currentOpt, {
235
+ onNone: () => null,
236
+ onSome: (p) => ({
237
+ id: p.id,
238
+ phase: p.phase,
239
+ startedAt: p.startedAt,
240
+ behaviorId: p.behaviorId
241
+ })
242
+ })
243
+ };
244
+ }),
245
+ resume: (variant) => Effect.gen(function* () {
246
+ const reader = yield* DataReader;
247
+ const tddOpt = yield* reader.getTddTaskById(variant.tddTaskId);
248
+ if (Option.isNone(tddOpt)) return {
249
+ action: "resume",
250
+ found: false,
251
+ tddTaskId: variant.tddTaskId
252
+ };
253
+ const tdd = tddOpt.value;
254
+ const currentOpt = yield* reader.getCurrentTddPhase(variant.tddTaskId);
255
+ return {
256
+ action: "resume",
257
+ found: true,
258
+ tddTaskId: tdd.id,
259
+ goal: tdd.goal,
260
+ status: tdd.outcome ?? "in progress",
261
+ currentPhase: Option.match(currentOpt, {
262
+ onNone: () => null,
263
+ onSome: (p) => ({
264
+ id: p.id,
265
+ phase: p.phase,
266
+ startedAt: p.startedAt,
267
+ behaviorId: p.behaviorId
268
+ })
269
+ }),
270
+ phasesRecorded: tdd.phases.length,
271
+ artifactsRecorded: tdd.artifacts.length
272
+ };
273
+ })
274
+ })));
275
+ });
276
+
277
+ //#endregion
278
+ export { TddTaskAsMarkdown, TddTaskResult, tddTask };
package/tools/test.js ADDED
@@ -0,0 +1,281 @@
1
+ import { publicProcedure } from "../context.js";
2
+ import { DataReader } from "@vitest-agent/sdk";
3
+ import { Effect, Match, Option, ParseResult, Schema } from "effect";
4
+
5
+ //#region src/tools/test.ts
6
+ /**
7
+ * Consolidated `test` MCP tool — Schema-driven implementation.
8
+ *
9
+ * Replaces `test_list`, `test_get`, and `test_for_file` with one
10
+ * tool keyed on `action`. Result variants discriminate on
11
+ * `inventoryKind` so a single Effect Schema describes every shape
12
+ * the tool can emit.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ const TestRowSchema = Schema.Struct({
17
+ id: Schema.Number,
18
+ fullName: Schema.String,
19
+ state: Schema.String,
20
+ duration: Schema.NullOr(Schema.Number),
21
+ module: Schema.String,
22
+ classification: Schema.NullOr(Schema.String)
23
+ }).annotations({ identifier: "TestListRow" });
24
+ const TestErrorRowMini = Schema.Struct({
25
+ name: Schema.NullOr(Schema.String),
26
+ message: Schema.String,
27
+ diff: Schema.NullOr(Schema.String),
28
+ stack: Schema.NullOr(Schema.String)
29
+ }).annotations({ identifier: "TestGetErrorRow" });
30
+ const TestRunRow = Schema.Struct({
31
+ state: Schema.Literal("passed", "failed"),
32
+ timestamp: Schema.String
33
+ }).annotations({ identifier: "TestGetRunRow" });
34
+ const TestListGroup = Schema.Struct({
35
+ project: Schema.String,
36
+ tests: Schema.Array(TestRowSchema)
37
+ });
38
+ const TestListResult = Schema.Struct({
39
+ action: Schema.Literal("list"),
40
+ count: Schema.Number,
41
+ groups: Schema.Array(TestListGroup)
42
+ }).annotations({ identifier: "TestList" });
43
+ const TestGetFound = Schema.Struct({
44
+ action: Schema.Literal("get"),
45
+ found: Schema.Literal(true),
46
+ project: Schema.String,
47
+ test: TestRowSchema,
48
+ errors: Schema.Array(TestErrorRowMini),
49
+ runs: Schema.Array(TestRunRow)
50
+ }).annotations({ identifier: "TestGetFound" });
51
+ const TestGetMissing = Schema.Struct({
52
+ action: Schema.Literal("get"),
53
+ found: Schema.Literal(false),
54
+ project: Schema.String,
55
+ fullName: Schema.String
56
+ }).annotations({ identifier: "TestGetMissing" });
57
+ const TestForFileResult = Schema.Struct({
58
+ action: Schema.Literal("for_file"),
59
+ filePath: Schema.String,
60
+ count: Schema.Number,
61
+ testFiles: Schema.Array(Schema.String)
62
+ }).annotations({ identifier: "TestForFile" });
63
+ const TestForTagResult = Schema.Struct({
64
+ action: Schema.Literal("for_tag"),
65
+ tag: Schema.String,
66
+ count: Schema.Number,
67
+ groups: Schema.Array(TestListGroup)
68
+ }).annotations({ identifier: "TestForTag" });
69
+ const TestResult = Schema.Union(TestListResult, TestGetFound, TestGetMissing, TestForFileResult, TestForTagResult).annotations({
70
+ identifier: "TestResult",
71
+ title: "test result",
72
+ description: "Discriminate on `action`. `get` further discriminates on `found`. `list`, `for_file`, and `for_tag` all carry counted arrays — `list` and `for_tag` group by project."
73
+ });
74
+ const formatTestMarkdown = (data) => {
75
+ if (data.action === "list") {
76
+ if (data.count === 0) return "No tests found. Run run_tests({}) to execute tests and populate the database.";
77
+ const lines = ["## Tests", ""];
78
+ for (const g of data.groups) {
79
+ lines.push(`### ${g.project}`, "", "| ID | Full Name | State | Duration | Module | Classification |", "| --- | --- | --- | --- | --- | --- |");
80
+ for (const t of g.tests) {
81
+ const duration = t.duration !== null ? `${t.duration}ms` : "—";
82
+ const classification = t.classification ?? "—";
83
+ lines.push(`| ${t.id} | ${t.fullName} | ${t.state} | ${duration} | ${t.module} | ${classification} |`);
84
+ }
85
+ lines.push("");
86
+ }
87
+ return lines.join("\n").trimEnd();
88
+ }
89
+ if (data.action === "get") {
90
+ if (!data.found) return `Test not found: \`${data.fullName}\`\n\nUse test({ action: "list" }) to discover available tests (format: "Suite > test name").`;
91
+ const t = data.test;
92
+ const lines = [
93
+ `# Test: ${t.fullName}`,
94
+ "",
95
+ "## Details",
96
+ "",
97
+ "| Field | Value |",
98
+ "| --- | --- |",
99
+ `| State | ${t.state} |`,
100
+ `| Duration | ${t.duration !== null ? `${t.duration}ms` : "—"} |`,
101
+ `| Module | \`${t.module}\` |`,
102
+ `| Classification | ${t.classification ?? "—"} |`,
103
+ ""
104
+ ];
105
+ if (data.errors.length > 0) {
106
+ lines.push("## Errors", "");
107
+ for (const err of data.errors) {
108
+ lines.push(`**${err.name ?? "(unnamed)"}**`);
109
+ lines.push(`> ${err.message.split("\n").join("\n> ")}`);
110
+ if (err.diff !== null) {
111
+ lines.push("", "```diff", err.diff.slice(0, 1e3));
112
+ if (err.diff.length > 1e3) lines.push(`... (truncated, ${err.diff.length} chars total)`);
113
+ lines.push("```");
114
+ }
115
+ if (err.stack !== null && err.diff === null) {
116
+ lines.push("", "```", err.stack.slice(0, 1e3));
117
+ if (err.stack.length > 1e3) lines.push(`... (truncated, ${err.stack.length} chars total)`);
118
+ lines.push("```");
119
+ }
120
+ lines.push("");
121
+ }
122
+ }
123
+ if (data.runs.length > 0) {
124
+ const viz = data.runs.map((r) => r.state === "passed" ? "P" : "F").join("");
125
+ const passCount = data.runs.filter((r) => r.state === "passed").length;
126
+ const failCount = data.runs.filter((r) => r.state === "failed").length;
127
+ lines.push("## Run History", "", `Pass rate: ${passCount}/${data.runs.length} (${Math.round(passCount / data.runs.length * 100)}%)`, `Recent runs: \`${viz}\` (P=passed F=failed S=skipped, newest last)`);
128
+ if (failCount > 0 && passCount > 0) lines.push("Pattern: **flaky** (mixed pass/fail)");
129
+ else if (failCount > 0) lines.push(`Pattern: **persistent failure** (${failCount} consecutive)`);
130
+ lines.push("");
131
+ }
132
+ if (t.state === "failed") lines.push("## Next steps", "", `- Re-run: run_tests({ files: ["${t.module}"] })`, `- Use test({ action: "for_file", filePath: "${t.module}" }) to find related tests`, "- Use note({ action: \"create\", ... }) to record debugging findings");
133
+ return lines.join("\n");
134
+ }
135
+ if (data.action === "for_tag") {
136
+ if (data.count === 0) return `No tests found tagged \`${data.tag}\`. Use \`inventory({ kind: "tag" })\` to discover available tags.`;
137
+ const lines = [
138
+ `# Tests tagged \`${data.tag}\``,
139
+ "",
140
+ `Found ${data.count} test${data.count === 1 ? "" : "s"} across ${data.groups.length} project${data.groups.length === 1 ? "" : "s"}:`,
141
+ ""
142
+ ];
143
+ for (const g of data.groups) {
144
+ lines.push(`### ${g.project}`, "", "| ID | Full Name | State | Duration | Module |", "| --- | --- | --- | --- | --- |");
145
+ for (const t of g.tests) {
146
+ const duration = t.duration !== null ? `${t.duration}ms` : "—";
147
+ lines.push(`| ${t.id} | ${t.fullName} | ${t.state} | ${duration} | ${t.module} |`);
148
+ }
149
+ lines.push("");
150
+ }
151
+ return lines.join("\n").trimEnd();
152
+ }
153
+ if (data.count === 0) return `No test modules found covering \`${data.filePath}\`. Run run_tests({}) to populate the database, or check the file path.`;
154
+ const lines = [
155
+ `# Tests for \`${data.filePath}\``,
156
+ "",
157
+ `Found ${data.count} test module${data.count === 1 ? "" : "s"}:`,
158
+ ""
159
+ ];
160
+ for (const f of data.testFiles) lines.push(`- \`${f}\``);
161
+ return lines.join("\n");
162
+ };
163
+ const TestAsMarkdown = Schema.transformOrFail(TestResult, Schema.String, {
164
+ strict: true,
165
+ decode: (data) => ParseResult.succeed(formatTestMarkdown(data)),
166
+ encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "TestAsMarkdown is one-way."))
167
+ });
168
+ const ListVariant = Schema.Struct({
169
+ action: Schema.Literal("list"),
170
+ project: Schema.optional(Schema.String),
171
+ state: Schema.optional(Schema.String),
172
+ module: Schema.optional(Schema.String),
173
+ limit: Schema.optional(Schema.Number)
174
+ });
175
+ const GetVariant = Schema.Struct({
176
+ action: Schema.Literal("get"),
177
+ fullName: Schema.String,
178
+ project: Schema.optional(Schema.String)
179
+ });
180
+ const ForFileVariant = Schema.Struct({
181
+ action: Schema.Literal("for_file"),
182
+ filePath: Schema.String
183
+ });
184
+ const ForTagVariant = Schema.Struct({
185
+ action: Schema.Literal("for_tag"),
186
+ tag: Schema.String,
187
+ project: Schema.optional(Schema.String)
188
+ });
189
+ const TestInput = Schema.Union(ListVariant, GetVariant, ForFileVariant, ForTagVariant);
190
+ const test = publicProcedure.input(Schema.standardSchemaV1(TestInput)).query(async ({ ctx, input }) => {
191
+ return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
192
+ list: (variant) => Effect.gen(function* () {
193
+ const reader = yield* DataReader;
194
+ const opts = {};
195
+ if (variant.state !== void 0) opts.state = variant.state;
196
+ if (variant.module !== void 0) opts.module = variant.module;
197
+ if (variant.limit !== void 0) opts.limit = variant.limit;
198
+ const targets = variant.project ? [{ project: variant.project }] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => ({ project: r.project }))));
199
+ const groups = [];
200
+ let total = 0;
201
+ for (const t of targets) {
202
+ const tests = yield* reader.listTests(t.project, opts);
203
+ if (tests.length > 0) {
204
+ groups.push({
205
+ project: t.project,
206
+ tests
207
+ });
208
+ total += tests.length;
209
+ }
210
+ }
211
+ return {
212
+ action: "list",
213
+ count: total,
214
+ groups
215
+ };
216
+ }),
217
+ get: (variant) => Effect.gen(function* () {
218
+ const reader = yield* DataReader;
219
+ const candidates = variant.project ? [variant.project] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => r.project)));
220
+ for (const project of candidates) {
221
+ const testOpt = yield* reader.getTestByFullName(project, variant.fullName);
222
+ if (Option.isNone(testOpt)) continue;
223
+ const matchingErrors = (yield* reader.getErrors(project)).filter((e) => e.testFullName === variant.fullName).map((e) => ({
224
+ name: e.name,
225
+ message: e.message,
226
+ diff: e.diff,
227
+ stack: e.stack
228
+ }));
229
+ const testHistory = (yield* reader.getHistory(project)).tests.find((entry) => entry.fullName === variant.fullName);
230
+ return {
231
+ action: "get",
232
+ found: true,
233
+ project,
234
+ test: testOpt.value,
235
+ errors: matchingErrors,
236
+ runs: testHistory ? testHistory.runs : []
237
+ };
238
+ }
239
+ return {
240
+ action: "get",
241
+ found: false,
242
+ project: variant.project ?? candidates[0] ?? "",
243
+ fullName: variant.fullName
244
+ };
245
+ }),
246
+ for_file: (variant) => Effect.gen(function* () {
247
+ const testFiles = yield* (yield* DataReader).getTestsForFile(variant.filePath);
248
+ return {
249
+ action: "for_file",
250
+ filePath: variant.filePath,
251
+ count: testFiles.length,
252
+ testFiles
253
+ };
254
+ }),
255
+ for_tag: (variant) => Effect.gen(function* () {
256
+ const reader = yield* DataReader;
257
+ const targets = variant.project ? [{ project: variant.project }] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => ({ project: r.project }))));
258
+ const groups = [];
259
+ let total = 0;
260
+ for (const t of targets) {
261
+ const tests = yield* reader.listTestsForTag(variant.tag, { project: t.project });
262
+ if (tests.length > 0) {
263
+ groups.push({
264
+ project: t.project,
265
+ tests
266
+ });
267
+ total += tests.length;
268
+ }
269
+ }
270
+ return {
271
+ action: "for_tag",
272
+ tag: variant.tag,
273
+ count: total,
274
+ groups
275
+ };
276
+ })
277
+ })));
278
+ });
279
+
280
+ //#endregion
281
+ export { TestAsMarkdown, TestResult, test };
@@ -0,0 +1,112 @@
1
+ import { publicProcedure } from "../context.js";
2
+ import { DataReader, TrendRecord } from "@vitest-agent/sdk";
3
+ import { Effect, Option, ParseResult, Schema } from "effect";
4
+
5
+ //#region src/tools/trends.ts
6
+ /**
7
+ * `test_trends` MCP tool — Schema-driven implementation.
8
+ *
9
+ * Wraps the existing `TrendRecord` Schema in a result envelope that
10
+ * carries the project name and a `dataAvailable` flag, so callers
11
+ * can distinguish "no trend data yet" from "data plus rendering"
12
+ * without parsing prose.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ const TrendsAvailable = Schema.Struct({
17
+ dataAvailable: Schema.Literal(true).annotations({ description: "Discriminant — `true` when at least one trend entry exists for the project." }),
18
+ project: Schema.String,
19
+ trends: TrendRecord.annotations({ description: "Trend entries oldest-first; the latest entry drives `direction` and the headline metrics." })
20
+ }).annotations({ identifier: "TestTrendsAvailable" });
21
+ const TrendsAbsent = Schema.Struct({
22
+ dataAvailable: Schema.Literal(false).annotations({ description: "Discriminant — `false` when fewer than two runs have been recorded for the project." }),
23
+ project: Schema.String
24
+ }).annotations({ identifier: "TestTrendsAbsent" });
25
+ const TestTrendsResult = Schema.Union(TrendsAvailable, TrendsAbsent).annotations({
26
+ identifier: "TestTrendsResult",
27
+ title: "test_trends result",
28
+ description: "Coverage trend record per project. Discriminate on `dataAvailable` to handle the cold-start case."
29
+ });
30
+ const SPARKLINE_CHARS = [
31
+ "▁",
32
+ "▂",
33
+ "▃",
34
+ "▄",
35
+ "▅",
36
+ "▆",
37
+ "▇",
38
+ "█"
39
+ ];
40
+ function toSparkline(values) {
41
+ if (values.length === 0) return "";
42
+ const min = Math.min(...values);
43
+ const range = Math.max(...values) - min;
44
+ return values.map((v) => {
45
+ return SPARKLINE_CHARS[range === 0 ? 4 : Math.round((v - min) / range * (SPARKLINE_CHARS.length - 1))] ?? "▄";
46
+ }).join("");
47
+ }
48
+ const directionIcon = (d) => d === "improving" ? "📈" : d === "regressing" ? "📉" : "➡️";
49
+ const formatTestTrendsMarkdown = (data) => {
50
+ if (data.dataAvailable === false) return `No trend data available for project \`${data.project}\`. Run tests multiple times to build trend history.`;
51
+ const entries = data.trends.entries;
52
+ const latest = entries[entries.length - 1];
53
+ if (latest === void 0) return `No trend data available for project \`${data.project}\`.`;
54
+ const lines = [`# Coverage Trends: ${data.project}`, ""];
55
+ lines.push(`${directionIcon(latest.direction)} **Overall direction:** ${latest.direction} over ${entries.length} run${entries.length === 1 ? "" : "s"}`);
56
+ lines.push("", "## Latest Coverage", "", "| Metric | Value | Δ |", "| --- | --- | --- |");
57
+ const metrics = [
58
+ "statements",
59
+ "branches",
60
+ "functions",
61
+ "lines"
62
+ ];
63
+ for (const metric of metrics) {
64
+ const value = latest.coverage[metric];
65
+ const delta = latest.delta[metric];
66
+ const deltaStr = delta > 0 ? `+${delta.toFixed(2)}%` : delta < 0 ? `${delta.toFixed(2)}%` : "—";
67
+ const deltaIcon = delta > .1 ? "↑" : delta < -.1 ? "↓" : "";
68
+ lines.push(`| ${metric} | ${value.toFixed(2)}% | ${deltaIcon} ${deltaStr} |`);
69
+ }
70
+ lines.push("");
71
+ if (entries.length >= 2) {
72
+ lines.push("## Trajectory", "");
73
+ for (const metric of metrics) {
74
+ const values = entries.map((e) => e.coverage[metric]);
75
+ lines.push(`- **${metric}**: \`${toSparkline(values)}\``);
76
+ }
77
+ lines.push("");
78
+ }
79
+ const recentEntries = entries.slice(-10);
80
+ if (recentEntries.length > 0) {
81
+ lines.push("## Recent Runs", "", "| Date | Lines | Branches | Functions | Statements | Direction |", "| --- | --- | --- | --- | --- | --- |");
82
+ for (const entry of recentEntries) {
83
+ const date = new Date(entry.timestamp).toLocaleDateString();
84
+ lines.push(`| ${date} | ${entry.coverage.lines.toFixed(1)}% | ${entry.coverage.branches.toFixed(1)}% | ${entry.coverage.functions.toFixed(1)}% | ${entry.coverage.statements.toFixed(1)}% | ${directionIcon(entry.direction)} |`);
85
+ }
86
+ lines.push("");
87
+ }
88
+ return lines.join("\n");
89
+ };
90
+ const TestTrendsAsMarkdown = Schema.transformOrFail(TestTrendsResult, Schema.String, {
91
+ strict: true,
92
+ decode: (data) => ParseResult.succeed(formatTestTrendsMarkdown(data)),
93
+ encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "TestTrendsAsMarkdown is one-way: markdown cannot be parsed back to TestTrendsResult."))
94
+ });
95
+ const testTrends = publicProcedure.input(Schema.standardSchemaV1(Schema.Struct({
96
+ project: Schema.String,
97
+ limit: Schema.optional(Schema.Number)
98
+ }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
99
+ const trendsOpt = yield* (yield* DataReader).getTrends(input.project, input.limit);
100
+ if (Option.isNone(trendsOpt) || trendsOpt.value.entries.length === 0) return {
101
+ dataAvailable: false,
102
+ project: input.project
103
+ };
104
+ return {
105
+ dataAvailable: true,
106
+ project: input.project,
107
+ trends: trendsOpt.value
108
+ };
109
+ })));
110
+
111
+ //#endregion
112
+ export { TestTrendsAsMarkdown, TestTrendsResult, testTrends };