@vitest-agent/mcp 2.4.11 → 2.4.13

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/index.d.ts CHANGED
@@ -245,6 +245,7 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
245
245
  } | undefined;
246
246
  readonly scoped: boolean;
247
247
  readonly scopedFiles?: readonly string[] | undefined;
248
+ readonly totalFiles?: number | undefined;
248
249
  readonly lowCoverage: readonly {
249
250
  readonly file: string;
250
251
  readonly summary: {
@@ -639,6 +640,7 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
639
640
  } | undefined;
640
641
  readonly scoped: boolean;
641
642
  readonly scopedFiles?: readonly string[] | undefined;
643
+ readonly totalFiles?: number | undefined;
642
644
  readonly lowCoverage: readonly {
643
645
  readonly file: string;
644
646
  readonly summary: {
@@ -679,12 +681,17 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
679
681
  readonly sample?: string | undefined;
680
682
  }[];
681
683
  readonly truncated?: boolean | undefined;
684
+ readonly fromFailingTests?: {
685
+ readonly total: number;
686
+ readonly files: number;
687
+ } | undefined;
682
688
  } | undefined;
683
689
  };
684
690
  readonly classifications: {
685
691
  readonly [x: string]: string;
686
692
  };
687
693
  readonly discoveryLastScannedAt?: string | null | undefined;
694
+ readonly scopedNote?: string | null | undefined;
688
695
  } | {
689
696
  readonly kind: "timeout";
690
697
  readonly timeoutSeconds: number;
@@ -1435,6 +1442,7 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
1435
1442
  readonly testRunId: number | null;
1436
1443
  readonly testFirstFailureRunId: number | null;
1437
1444
  readonly recordedAt: string;
1445
+ readonly suite: "bats" | "vitest";
1438
1446
  }[];
1439
1447
  };
1440
1448
  meta: object;
package/index.js CHANGED
@@ -12,7 +12,7 @@ import { parseSessionEnvExports, recoverSessionContextFromSessionEnv } from "./s
12
12
  *
13
13
  * @public
14
14
  */
15
- const CURRENT_MCP_VERSION = "2.4.11";
15
+ const CURRENT_MCP_VERSION = "2.4.13";
16
16
 
17
17
  //#endregion
18
18
  export { CURRENT_MCP_VERSION, McpLive, appRouter, buildMcpServer, createCallerFactory, createCurrentSessionIdRef, createSessionContextRef, parseSessionEnvExports, recoverSessionContextFromSessionEnv, startMcpServer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/mcp",
3
- "version": "2.4.11",
3
+ "version": "2.4.13",
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": [
@@ -42,9 +42,9 @@
42
42
  "@effect/sql-sqlite-node": "4.0.0-rc.109",
43
43
  "@modelcontextprotocol/sdk": "^1.30.0",
44
44
  "@trpc/server": "^11.18.0",
45
- "@vitest-agent/sdk": "2.4.13",
45
+ "@vitest-agent/sdk": "2.5.0",
46
46
  "effect": "4.0.0-rc.109",
47
- "zod": "^4.4.3"
47
+ "zod": "^4.5.4"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "vitest": "^4.1.0"
package/server.js CHANGED
@@ -9,9 +9,9 @@ import { FailureSignatureGetAsMarkdown, FailureSignatureGetResult } from "./tool
9
9
  import { FileCoverageAsMarkdown, FileCoverageResult } from "./tools/file-coverage.js";
10
10
  import { HelpResult } from "./tools/help.js";
11
11
  import { TestHistoryAsMarkdown, TestHistoryResult } from "./tools/history.js";
12
- import { HypothesisResult, formatHypothesisListMarkdown } from "./tools/hypothesis.js";
13
- import { InventoryAsMarkdown, InventoryResult } from "./tools/inventory.js";
14
- import { NoteResult, formatNoteListMarkdown } from "./tools/note.js";
12
+ import { HYPOTHESIS_ACTIONS, HypothesisResult, formatHypothesisListMarkdown } from "./tools/hypothesis.js";
13
+ import { INVENTORY_KINDS, InventoryAsMarkdown, InventoryResult } from "./tools/inventory.js";
14
+ import { NOTE_ACTIONS, NoteResult, formatNoteListMarkdown } from "./tools/note.js";
15
15
  import { TestOverviewAsMarkdown, TestOverviewResult } from "./tools/overview.js";
16
16
  import { PingResult } from "./tools/ping.js";
17
17
  import { RegisterAgentResult } from "./tools/register-agent.js";
@@ -19,11 +19,11 @@ import { RunTestsAsMarkdown, RunTestsResult } from "./tools/run-tests.js";
19
19
  import { SettingsListAsMarkdown, SettingsListResult } from "./tools/settings-list.js";
20
20
  import { TestStatusAsMarkdown, TestStatusResult } from "./tools/status.js";
21
21
  import { TddArtifactListAsMarkdown, TddArtifactListResult } from "./tools/tdd-artifact.js";
22
- import { TddBehaviorResult } from "./tools/tdd-behavior.js";
23
- import { TddGoalResult } from "./tools/tdd-goal.js";
22
+ import { TDD_BEHAVIOR_ACTIONS, TddBehaviorResult } from "./tools/tdd-behavior.js";
23
+ import { TDD_GOAL_ACTIONS, TddGoalResult } from "./tools/tdd-goal.js";
24
24
  import { PhaseTransitionResult } from "./tools/tdd-phase-transition-request.js";
25
- import { TddTaskAsMarkdown, TddTaskResult } from "./tools/tdd-task.js";
26
- import { TestAsMarkdown, TestResult } from "./tools/test.js";
25
+ import { TDD_TASK_ACTIONS, TddTaskAsMarkdown, TddTaskResult } from "./tools/tdd-task.js";
26
+ import { TEST_ACTIONS, TestAsMarkdown, TestResult } from "./tools/test.js";
27
27
  import { TestTrendsAsMarkdown, TestTrendsResult } from "./tools/trends.js";
28
28
  import { TriageBriefResult } from "./tools/triage-brief.js";
29
29
  import { TurnSearchAsMarkdown, TurnSearchResult } from "./tools/turn-search.js";
@@ -259,20 +259,17 @@ function buildMcpServer(ctx) {
259
259
  return structuredResult(Schema.decodeSync(TestErrorsAsMarkdown)(data), data);
260
260
  });
261
261
  server.registerTool("test", {
262
- description: "Use to inspect tests, with an action discriminator: action='list' (project?, state?, module?, limit?) returns matching tests; action='get' (fullName, project?, modulePath?) returns details + errors + run history — a fullName that exists in more than one module returns found=false with ambiguous=true and candidateModules[], so pass modulePath to disambiguate; action='for_file' (filePath) returns test modules covering a source file. structuredContent carries the typed payload (discriminate on `action`, then on `found` for get).",
262
+ description: "Use to inspect tests, with an action discriminator: action='list' (project?, state?, module?, limit?) returns matching tests; action='get' (fullName, project?, modulePath?) returns details + errors + run history — a fullName that exists in more than one module returns found=false with ambiguous=true and candidateModules[], so pass modulePath to disambiguate; action='for_file' (filePath) returns test modules covering a source file; action='for_tag' (tag, project?) returns tests carrying a tag, grouped by project. structuredContent carries the typed payload (discriminate on `action`, then on `found` for get).",
263
263
  inputSchema: strict({
264
- action: z.enum([
265
- "list",
266
- "get",
267
- "for_file"
268
- ]).describe("Inspection discriminator"),
264
+ action: z.enum(TEST_ACTIONS).describe("Inspection discriminator"),
269
265
  project: z.optional(z.string()),
270
266
  state: z.optional(z.string()).describe("list: filter by state"),
271
267
  module: z.optional(z.string()).describe("list: filter by module path"),
272
268
  limit: z.optional(z.coerce.number()).describe("list: max rows to return"),
273
269
  fullName: z.optional(z.string()).describe("get: full test name"),
274
270
  modulePath: z.optional(z.string()).describe("get: exact module path, disambiguating a fullName present in several files"),
275
- filePath: z.optional(z.string()).describe("for_file: source file path")
271
+ filePath: z.optional(z.string()).describe("for_file: source file path"),
272
+ tag: z.optional(z.string()).describe("for_tag: tag name")
276
273
  }),
277
274
  outputSchema: effectToZodSchema(TestResult)
278
275
  }, async (args) => {
@@ -290,10 +287,15 @@ function buildMcpServer(ctx) {
290
287
  ...args.project !== void 0 && { project: args.project },
291
288
  ...args.modulePath !== void 0 && { modulePath: args.modulePath }
292
289
  });
293
- else data = await caller.test({
290
+ else if (args.action === "for_file") data = await caller.test({
294
291
  action: "for_file",
295
292
  filePath: args.filePath
296
293
  });
294
+ else data = await caller.test({
295
+ action: "for_tag",
296
+ tag: args.tag,
297
+ ...args.project !== void 0 && { project: args.project }
298
+ });
297
299
  return structuredResult(Schema.decodeSync(TestAsMarkdown)(data), data);
298
300
  });
299
301
  server.registerTool("file_coverage", {
@@ -326,14 +328,9 @@ function buildMcpServer(ctx) {
326
328
  return structuredResult(Schema.decodeSync(CacheHealthAsMarkdown)(data), data);
327
329
  });
328
330
  server.registerTool("inventory", {
329
- description: "Use to discover what exists in the workspace, with a kind discriminator: project / module / suite / session. structuredContent discriminates on `inventoryKind` (project, module, suite, session_detail, session_list) so callers can branch on the response shape without parsing markdown.",
331
+ description: "Use to discover what exists in the workspace, with a kind discriminator: project / module / suite / session / tag. structuredContent discriminates on `inventoryKind` (project, module, suite, session_detail, session_list, tag_scoped, tag_unscoped) so callers can branch on the response shape without parsing markdown.",
330
332
  inputSchema: strict({
331
- kind: z.enum([
332
- "project",
333
- "module",
334
- "suite",
335
- "session"
336
- ]).describe("Inventory entity"),
333
+ kind: z.enum(INVENTORY_KINDS).describe("Inventory entity"),
337
334
  id: z.optional(z.coerce.number()).describe("session: single-row lookup by id"),
338
335
  project: z.optional(z.string()),
339
336
  module: z.optional(z.string()).describe("suite: filter by module path"),
@@ -353,13 +350,17 @@ function buildMcpServer(ctx) {
353
350
  ...args.project !== void 0 && { project: args.project },
354
351
  ...args.module !== void 0 && { module: args.module }
355
352
  });
356
- else data = await caller.inventory({
353
+ else if (args.kind === "session") data = await caller.inventory({
357
354
  kind: "session",
358
355
  ...args.id !== void 0 && { id: args.id },
359
356
  ...args.project !== void 0 && { project: args.project },
360
357
  ...args.agentKind !== void 0 && { agentKind: args.agentKind },
361
358
  ...args.limit !== void 0 && { limit: args.limit }
362
359
  });
360
+ else data = await caller.inventory({
361
+ kind: "tag",
362
+ ...args.project !== void 0 && { project: args.project }
363
+ });
363
364
  return structuredResult(Schema.decodeSync(InventoryAsMarkdown)(data), data);
364
365
  });
365
366
  server.registerTool("settings_list", {
@@ -439,14 +440,7 @@ function buildMcpServer(ctx) {
439
440
  server.registerTool("note", {
440
441
  description: "Use to manage notes, with a CRUD action discriminator: action='create' writes a scoped note; action='list' (scope?, project?, testFullName?) returns matching notes; action='get' (id) returns a structured note; action='update' (id, ...patch) edits; action='delete' (id) removes; action='search' (query) does FTS5 across title and content. structuredContent always carries the typed result (discriminate on `action`); list/search additionally render markdown in the text channel.",
441
442
  inputSchema: strict({
442
- action: z.enum([
443
- "create",
444
- "list",
445
- "get",
446
- "update",
447
- "delete",
448
- "search"
449
- ]).describe("CRUD discriminator"),
443
+ action: z.enum(NOTE_ACTIONS).describe("CRUD discriminator"),
450
444
  id: z.optional(z.coerce.number()).describe("get/update/delete: note id"),
451
445
  project: z.optional(z.string()),
452
446
  title: z.optional(z.string()),
@@ -550,12 +544,7 @@ function buildMcpServer(ctx) {
550
544
  server.registerTool("tdd_task", {
551
545
  description: "Use to manage a TDD task lifecycle, with an action discriminator: action='start' (goal, sessionId|chatId, parentTddTaskId?, startedAt?, runId?) opens a new task; action='end' (tddTaskId, outcome, summaryNoteId?) closes one; action='get' (tddTaskId) returns markdown details; action='resume' (tddTaskId) returns a compact digest.",
552
546
  inputSchema: strict({
553
- action: z.enum([
554
- "start",
555
- "end",
556
- "get",
557
- "resume"
558
- ]).describe("Lifecycle discriminator"),
547
+ action: z.enum(TDD_TASK_ACTIONS).describe("Lifecycle discriminator"),
559
548
  tddTaskId: z.optional(z.coerce.number()).describe("end/get/resume: tdd task id"),
560
549
  goal: z.optional(z.string()).describe("start: goal text"),
561
550
  sessionId: z.optional(z.coerce.number()).describe("start: sessions.id (alternative to chatId)"),
@@ -638,13 +627,7 @@ function buildMcpServer(ctx) {
638
627
  server.registerTool("tdd_goal", {
639
628
  description: "Use to manage TDD goals, with a CRUD action discriminator: action='create' (tddTaskId, goal) is idempotent on (tddTaskId, goal); action='update' (id, goal?, status?) edits text and/or lifecycle status; action='delete' (id) hard-deletes (prefer status:'abandoned'); action='get' (id) reads with nested behaviors; action='list' (tddTaskId) returns all goals for a TDD task.",
640
629
  inputSchema: strict({
641
- action: z.enum([
642
- "create",
643
- "update",
644
- "delete",
645
- "get",
646
- "list"
647
- ]).describe("CRUD discriminator"),
630
+ action: z.enum(TDD_GOAL_ACTIONS).describe("CRUD discriminator"),
648
631
  id: z.optional(z.coerce.number()).describe("update/delete/get: goal id"),
649
632
  tddTaskId: z.optional(z.coerce.number()).describe("create/list: tdd task id"),
650
633
  goal: z.optional(z.string()),
@@ -684,14 +667,7 @@ function buildMcpServer(ctx) {
684
667
  server.registerTool("tdd_behavior", {
685
668
  description: "Use to manage TDD behaviors, with a CRUD action discriminator: action='create' (goalId, behavior, suggestedTestName?, dependsOnBehaviorIds?) is idempotent on (goalId, behavior); action='update' (id, ...patch) edits; action='delete' (id) hard-deletes; action='get' (id) reads; action='list_by_goal' (goalId) lists one goal's behaviors; action='list_by_tdd_task' (tddTaskId) lists across all goals.",
686
669
  inputSchema: strict({
687
- action: z.enum([
688
- "create",
689
- "update",
690
- "delete",
691
- "get",
692
- "list_by_goal",
693
- "list_by_tdd_task"
694
- ]).describe("CRUD discriminator"),
670
+ action: z.enum(TDD_BEHAVIOR_ACTIONS).describe("CRUD discriminator"),
695
671
  id: z.optional(z.coerce.number()),
696
672
  goalId: z.optional(z.coerce.number()),
697
673
  tddTaskId: z.optional(z.coerce.number()),
@@ -769,11 +745,7 @@ function buildMcpServer(ctx) {
769
745
  server.registerTool("hypothesis", {
770
746
  description: "Use to manage debugging hypotheses, with a CRUD action discriminator: action='record' (content, tddTaskId?, optional citation ids) writes a hypothesis — the binding session is resolved server-side from the recovered host context (active TDD subagent, else main session); pass tddTaskId (returned by tdd_task action='start') to bind deterministically to that task's session, and do not pass sessionId when recording; action='validate' (id, outcome, validatedAt?) records a validation outcome — validatedAt is optional and defaults server-side to now when omitted, or is honored verbatim when supplied; action='list' (sessionId?, outcome?, limit?) returns matching hypotheses as markdown.",
771
747
  inputSchema: strict({
772
- action: z.enum([
773
- "record",
774
- "validate",
775
- "list"
776
- ]).describe("CRUD discriminator"),
748
+ action: z.enum(HYPOTHESIS_ACTIONS).describe("CRUD discriminator"),
777
749
  sessionId: z.optional(z.coerce.number()).describe("list: filter by session id. record: dev/test fallback only — ignored when host context is recovered; never pass a tddTaskId value here"),
778
750
  tddTaskId: z.optional(z.coerce.number()).describe("record: tdd task id returned by tdd_task action='start' — binds the hypothesis to that task's session deterministically"),
779
751
  content: z.optional(z.string()).describe("Hypothesis content (action=record)"),
package/tools/coverage.js CHANGED
@@ -3,11 +3,6 @@ import { CoverageReport, DataReader } from "@vitest-agent/sdk";
3
3
  import { Effect, Option, Schema, SchemaGetter } from "effect";
4
4
 
5
5
  //#region src/tools/coverage.ts
6
- /**
7
- * `test_coverage` MCP tool — Schema-driven implementation.
8
- *
9
- * @packageDocumentation
10
- */
11
6
  const CoverageAvailable = Schema.Struct({
12
7
  dataAvailable: Schema.Literal(true),
13
8
  project: Schema.String,
@@ -22,34 +17,49 @@ const TestCoverageResult = Schema.Union([CoverageAvailable, CoverageAbsent]).ann
22
17
  title: "test_coverage result",
23
18
  description: "Per-project coverage report. Discriminate on `dataAvailable` for cold-start handling."
24
19
  });
20
+ const METRICS = [
21
+ "statements",
22
+ "branches",
23
+ "functions",
24
+ "lines"
25
+ ];
26
+ /** @internal */
27
+ const renderFileCoverageTable = (fileCoverage) => {
28
+ const rows = [
29
+ `### \`${fileCoverage.file}\``,
30
+ "",
31
+ "| Metric | Value |",
32
+ "| --- | --- |"
33
+ ];
34
+ for (const metric of METRICS) rows.push(`| ${metric} | ${fileCoverage.summary[metric].toFixed(2)}% |`);
35
+ if (fileCoverage.uncoveredLines) rows.push(`| Uncovered lines | \`${fileCoverage.uncoveredLines}\` |`);
36
+ rows.push("");
37
+ return rows;
38
+ };
25
39
  const formatTestCoverageMarkdown = (data) => {
26
40
  if (!data.dataAvailable) return "No coverage data available. Run tests with coverage enabled.";
27
41
  const lines = ["# Coverage Report", ""];
28
- const { totals, thresholds } = data.coverage;
29
- lines.push("## Totals", "", "| Metric | Value | Threshold |", "| --- | --- | --- |");
30
- const metrics = [
31
- "statements",
32
- "branches",
33
- "functions",
34
- "lines"
35
- ];
36
- for (const metric of metrics) {
42
+ const { totals, thresholds, targets } = data.coverage;
43
+ const headerCols = targets ? "| Metric | Value | Enforced threshold | Target |" : "| Metric | Value | Enforced threshold |";
44
+ const sepCols = targets ? "| --- | --- | --- | --- |" : "| --- | --- | --- |";
45
+ lines.push("## Totals", "", headerCols, sepCols);
46
+ for (const metric of METRICS) {
37
47
  const value = totals[metric];
38
48
  const threshold = thresholds.global[metric];
39
49
  const thresholdStr = threshold !== void 0 ? `${threshold}%` : "—";
40
50
  const icon = threshold !== void 0 && value < threshold ? "❌" : "✅";
41
- lines.push(`| ${metric} | ${icon} ${value.toFixed(2)}% | ${thresholdStr} |`);
51
+ const targetStr = targets ? targets.global[metric] !== void 0 ? `${targets.global[metric]}%` : "—" : void 0;
52
+ lines.push(`| ${metric} | ${icon} ${value.toFixed(2)}% | ${thresholdStr} |${targetStr !== void 0 ? ` ${targetStr} |` : ""}`);
42
53
  }
43
54
  lines.push("");
44
55
  if (data.coverage.lowCoverage.length > 0) {
45
- lines.push("## Coverage Gaps", "", "Files below coverage threshold:", "");
46
- for (const fileCoverage of data.coverage.lowCoverage) {
47
- lines.push(`### \`${fileCoverage.file}\``, "", "| Metric | Value |", "| --- | --- |");
48
- for (const metric of metrics) lines.push(`| ${metric} | ${fileCoverage.summary[metric].toFixed(2)}% |`);
49
- if (fileCoverage.uncoveredLines) lines.push(`| Uncovered lines | \`${fileCoverage.uncoveredLines}\` |`);
50
- lines.push("");
51
- }
56
+ lines.push("## Coverage Gaps", "", "Files below the enforced threshold (build-blocking):", "");
57
+ for (const fileCoverage of data.coverage.lowCoverage) lines.push(...renderFileCoverageTable(fileCoverage));
52
58
  } else lines.push("✅ All files meet coverage thresholds.", "");
59
+ if (data.coverage.belowTarget && data.coverage.belowTarget.length > 0) {
60
+ lines.push("## Coverage Improvements Needed", "", "Files below the aspirational target (passing the enforced threshold):", "");
61
+ for (const fileCoverage of data.coverage.belowTarget) lines.push(...renderFileCoverageTable(fileCoverage));
62
+ }
53
63
  return lines.join("\n");
54
64
  };
55
65
  const TestCoverageAsMarkdown = TestCoverageResult.pipe(Schema.decodeTo(Schema.String, {
@@ -91,6 +91,17 @@ const HypothesisInput = Schema.Union([
91
91
  ValidateVariant,
92
92
  ListVariant
93
93
  ]);
94
+ /**
95
+ * Single source of truth for the `hypothesis` tool's `action`
96
+ * discriminant, consumed by `server.ts`'s served `z.enum(...)` so the
97
+ * MCP-SDK-side registration cannot drift from this tRPC input union
98
+ * (issue #335).
99
+ */
100
+ const HYPOTHESIS_ACTIONS = [
101
+ "record",
102
+ "validate",
103
+ "list"
104
+ ];
94
105
  const hypothesis = idempotentProcedure.input(Schema.toStandardSchemaV1(HypothesisInput)).mutation(async ({ ctx, input }) => {
95
106
  return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
96
107
  record: (variant) => Effect.gen(function* () {
@@ -157,4 +168,4 @@ const hypothesis = idempotentProcedure.input(Schema.toStandardSchemaV1(Hypothesi
157
168
  });
158
169
 
159
170
  //#endregion
160
- export { HypothesisResult, formatHypothesisListMarkdown, hypothesis };
171
+ export { HYPOTHESIS_ACTIONS, HypothesisResult, formatHypothesisListMarkdown, hypothesis };
@@ -255,6 +255,18 @@ const InventoryInput = Schema.Union([
255
255
  SessionVariant,
256
256
  TagVariant
257
257
  ]);
258
+ /**
259
+ * Single source of truth for the `inventory` tool's `kind` discriminant,
260
+ * consumed by `server.ts`'s served `z.enum(...)` so the MCP-SDK-side
261
+ * registration cannot drift from this tRPC input union (issue #335).
262
+ */
263
+ const INVENTORY_KINDS = [
264
+ "project",
265
+ "module",
266
+ "suite",
267
+ "session",
268
+ "tag"
269
+ ];
258
270
  const inventory = publicProcedure.input(Schema.toStandardSchemaV1(InventoryInput)).query(async ({ ctx, input }) => {
259
271
  return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("kind")({
260
272
  project: () => Effect.gen(function* () {
@@ -377,4 +389,4 @@ const inventory = publicProcedure.input(Schema.toStandardSchemaV1(InventoryInput
377
389
  });
378
390
 
379
391
  //#endregion
380
- export { InventoryAsMarkdown, InventoryResult, formatInventoryMarkdown, inventory };
392
+ export { INVENTORY_KINDS, InventoryAsMarkdown, InventoryResult, formatInventoryMarkdown, inventory };
package/tools/note.js CHANGED
@@ -152,6 +152,19 @@ const NoteInputUnion = Schema.Union([
152
152
  DeleteVariant,
153
153
  SearchVariant
154
154
  ]);
155
+ /**
156
+ * Single source of truth for the `note` tool's `action` discriminant,
157
+ * consumed by `server.ts`'s served `z.enum(...)` so the MCP-SDK-side
158
+ * registration cannot drift from this tRPC input union (issue #335).
159
+ */
160
+ const NOTE_ACTIONS = [
161
+ "create",
162
+ "list",
163
+ "get",
164
+ "update",
165
+ "delete",
166
+ "search"
167
+ ];
155
168
  const note = publicProcedure.input(Schema.toStandardSchemaV1(NoteInputUnion)).mutation(async ({ ctx, input }) => {
156
169
  return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
157
170
  create: (variant) => Effect.gen(function* () {
@@ -227,4 +240,4 @@ const note = publicProcedure.input(Schema.toStandardSchemaV1(NoteInputUnion)).mu
227
240
  });
228
241
 
229
242
  //#endregion
230
- export { NoteResult, formatNoteListMarkdown, note };
243
+ export { NOTE_ACTIONS, NoteResult, formatNoteListMarkdown, note };
@@ -1,7 +1,7 @@
1
1
  import { publicProcedure } from "../context.js";
2
2
  import { createRequire } from "node:module";
3
- import { AgentReport, DataReader, DataStore, buildAgentReport, buildConsoleLeaks, coerceErrorField, collectConsoleLeakEntries } from "@vitest-agent/sdk";
4
- import { Effect, Schema, SchemaGetter, Semaphore } from "effect";
3
+ import { AgentReport, DataReader, DataStore, buildAgentReport, buildConsoleLeaks, coerceErrorField, collectConsoleLeakEntries, formatScopedCoverageNote } from "@vitest-agent/sdk";
4
+ import { Data, Effect, Schema, SchemaGetter, Semaphore } from "effect";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { execFile } from "node:child_process";
7
7
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
@@ -36,7 +36,8 @@ const RunTestsOk = Schema.Struct({
36
36
  scope: RunTestsScope,
37
37
  report: AgentReport.annotate({ description: "Full AgentReport including pass/fail counts and per-module errors." }),
38
38
  classifications: Schema.Record(Schema.String, Schema.String).annotate({ description: "Per-test classification labels: stable, new-failure, persistent, flaky, recovered." }),
39
- 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." })
39
+ 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." }),
40
+ 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." })
40
41
  }).annotate({ identifier: "RunTestsOk" });
41
42
  const RunTestsTimeout = Schema.Struct({
42
43
  kind: Schema.Literal("timeout"),
@@ -124,6 +125,12 @@ const makeCoverageDirOverride = () => {
124
125
  coverage: { reportsDirectory: dir }
125
126
  };
126
127
  };
128
+ /**
129
+ * Typed wrapper for a rejected `vitest.start(...)` promise (issue #320).
130
+ * Keeps the run's Effect error channel a tagged union so the timeout
131
+ * branch can be recovered by tag instead of by string sentinel.
132
+ */
133
+ var VitestStartFailure = class extends Data.TaggedError("VitestStartFailure") {};
127
134
  const stdoutSinkStorage = new AsyncLocalStorage();
128
135
  const stderrSinkStorage = new AsyncLocalStorage();
129
136
  let _stdioPatched = false;
@@ -358,7 +365,7 @@ function formatRunTestsMarkdown(data) {
358
365
  const rootLine = `\nProject root: \`${data.projectRoot}\``;
359
366
  if (data.kind === "no-match") return `${formatNoMatchMarkdown(data.filter)}${rootLine}`;
360
367
  const classMap = new Map(Object.entries(data.classifications));
361
- return `${formatReportMarkdown(data.report, classMap)}${rootLine}`;
368
+ return `${formatReportMarkdown(data.report, classMap, data.scopedNote ?? void 0)}${rootLine}`;
362
369
  }
363
370
  /**
364
371
  * Render the `no-match` filter context plus a remediation pointer aimed at
@@ -403,7 +410,7 @@ const RunTestsAsMarkdown = RunTestsResult.pipe(Schema.decodeTo(Schema.String, {
403
410
  *
404
411
  * @internal
405
412
  */
406
- function formatReportMarkdown(report, classifications) {
413
+ function formatReportMarkdown(report, classifications, scopedNote) {
407
414
  const lines = [];
408
415
  const { summary } = report;
409
416
  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);
@@ -417,12 +424,19 @@ function formatReportMarkdown(report, classifications) {
417
424
  if (summary.skipped > 0) headlineParts.push(`${summary.skipped} skipped`);
418
425
  lines.push(`## ${status} Vitest -- ${headlineParts.join(", ")} (${summary.duration}ms)`);
419
426
  if (report.project) lines.push(`\nProject: ${report.project}`);
427
+ if (scopedNote !== void 0) lines.push(`\n${scopedNote}`);
420
428
  if (report.consoleLeaks !== void 0) {
421
429
  const cl = report.consoleLeaks;
422
- const writes = `${cl.total} stray console write${cl.total === 1 ? "" : "s"}`;
423
- const plural = cl.byFile.length !== 1 || cl.truncated === true;
424
- const files = `${cl.byFile.length}${cl.truncated === true ? "+" : ""} file${plural ? "s" : ""}`;
425
- lines.push(`\n⚠ ${writes} across ${files} (see consoleLeaks)`);
430
+ if (cl.total > 0) {
431
+ const writes = `${cl.total} stray console write${cl.total === 1 ? "" : "s"}`;
432
+ const plural = cl.byFile.length !== 1 || cl.truncated === true;
433
+ const files = `${cl.byFile.length}${cl.truncated === true ? "+" : ""} file${plural ? "s" : ""}`;
434
+ lines.push(`\n⚠ ${writes} across ${files} (see consoleLeaks)`);
435
+ }
436
+ if (cl.fromFailingTests !== void 0) {
437
+ const { total: failingTotal } = cl.fromFailingTests;
438
+ lines.push(`\n${failingTotal} console write${failingTotal === 1 ? "" : "s"} from failing tests (not counted as leaks)`);
439
+ }
426
440
  }
427
441
  for (const mod of report.failed) {
428
442
  lines.push(`\n### \u274C \`${mod.file}\``);
@@ -521,12 +535,22 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
521
535
  stderr: nullStream
522
536
  });
523
537
  const localVitest = vitest;
524
- let timeoutHandle;
525
- const result = await withStdioCaptured(nullStream, () => Promise.race([localVitest.start(files.length > 0 ? files : void 0), new Promise((_, reject) => {
526
- timeoutHandle = setTimeout(() => reject(/* @__PURE__ */ new Error("VITEST_TIMEOUT")), timeoutMs);
527
- })]).finally(() => {
528
- if (timeoutHandle !== void 0) clearTimeout(timeoutHandle);
529
- }));
538
+ const startOutcome = await withStdioCaptured(nullStream, () => Effect.runPromise(Effect.tryPromise({
539
+ try: () => localVitest.start(files.length > 0 ? files : void 0),
540
+ catch: (cause) => new VitestStartFailure({ cause })
541
+ }).pipe(Effect.timeout(timeoutMs), Effect.map((value) => ({
542
+ outcome: "ok",
543
+ value
544
+ })), Effect.catchTag("TimeoutError", () => Effect.succeed({ outcome: "timeout" })), Effect.catchTag("VitestStartFailure", (e) => Effect.succeed({
545
+ outcome: "failed",
546
+ cause: e.cause
547
+ })))));
548
+ if (startOutcome.outcome === "timeout") return {
549
+ kind: "timeout",
550
+ timeoutSeconds: input.timeout ?? 120
551
+ };
552
+ if (startOutcome.outcome === "failed") throw startOutcome.cause;
553
+ const result = startOutcome.value;
530
554
  const testModules = result.testModules;
531
555
  const unhandledErrors = coerceErrors(result.unhandledErrors);
532
556
  if (hasFilter && result.testModules.length === 0 && unhandledErrors.length === 0) return {
@@ -566,6 +590,15 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
566
590
  invocationMethod: "mcp"
567
591
  });
568
592
  })).catch(() => void 0);
593
+ let scopedNote = null;
594
+ if (hasFilter) {
595
+ const testedFileCount = testModules.length;
596
+ let totalFileCount;
597
+ try {
598
+ totalFileCount = (await localVitest.globTestSpecifications()).length;
599
+ } catch {}
600
+ scopedNote = formatScopedCoverageNote(testedFileCount, totalFileCount);
601
+ }
569
602
  return {
570
603
  kind: "ok",
571
604
  ...project !== void 0 && { project },
@@ -577,15 +610,12 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
577
610
  },
578
611
  report,
579
612
  classifications: classifications ? Object.fromEntries(classifications) : {},
580
- discoveryLastScannedAt: readDiscoveryLastScannedAt() ?? null
613
+ discoveryLastScannedAt: readDiscoveryLastScannedAt() ?? null,
614
+ scopedNote
581
615
  };
582
616
  } catch (err) {
583
617
  let message;
584
618
  try {
585
- if (err instanceof Error && err.message === "VITEST_TIMEOUT") return {
586
- kind: "timeout",
587
- timeoutSeconds: input.timeout ?? 120
588
- };
589
619
  message = err instanceof Error ? err.message : String(err);
590
620
  } catch {
591
621
  message = coerceErrorField(err, "message") ?? "<unserializable error>";
@@ -45,7 +45,8 @@ const TddArtifactRow = Schema.Struct({
45
45
  testCaseId: Schema.NullOr(Schema.Number),
46
46
  testRunId: Schema.NullOr(Schema.Number),
47
47
  testFirstFailureRunId: Schema.NullOr(Schema.Number),
48
- recordedAt: Schema.String
48
+ recordedAt: Schema.String,
49
+ suite: Schema.Literals(["vitest", "bats"]).annotate({ description: "Which test runner produced this artifact — a bats run carries no test_case_id." })
49
50
  }).annotate({ identifier: "TddArtifactListRow" });
50
51
  const ArtifactFilters = Schema.Struct({
51
52
  artifactKind: Schema.optional(ArtifactKindSchema),
@@ -73,7 +74,7 @@ const formatTddArtifactListMarkdown = (data) => {
73
74
  if (data.count === 0) return `No artifacts recorded for tdd_task ${data.tddTaskId}${describeFilters(data.filters)}.`;
74
75
  const lines = [`# Artifacts for tdd_task ${data.tddTaskId} (newest first, ${data.count} shown)`, ""];
75
76
  for (const r of data.artifacts) {
76
- const extras = [`phase=${r.phaseName} [phaseId=${r.phaseId}]`];
77
+ const extras = [`phase=${r.phaseName} [phaseId=${r.phaseId}]`, `suite=${r.suite}`];
77
78
  if (r.behaviorId !== null) extras.push(`behaviorId=${r.behaviorId}`);
78
79
  if (r.testCaseId !== null) extras.push(`testCaseId=${r.testCaseId}`);
79
80
  if (r.testRunId !== null) extras.push(`testRunId=${r.testRunId}`);
@@ -117,6 +117,20 @@ const TddBehaviorInput = Schema.Union([
117
117
  ListByGoalVariant,
118
118
  ListByTddTaskVariant
119
119
  ]);
120
+ /**
121
+ * Single source of truth for the `tdd_behavior` tool's `action`
122
+ * discriminant, consumed by `server.ts`'s served `z.enum(...)` so the
123
+ * MCP-SDK-side registration cannot drift from this tRPC input union
124
+ * (issue #335).
125
+ */
126
+ const TDD_BEHAVIOR_ACTIONS = [
127
+ "create",
128
+ "update",
129
+ "delete",
130
+ "get",
131
+ "list_by_goal",
132
+ "list_by_tdd_task"
133
+ ];
120
134
  const tddBehavior = idempotentProcedure.input(Schema.toStandardSchemaV1(TddBehaviorInput)).mutation(async ({ ctx, input }) => {
121
135
  return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
122
136
  create: (variant) => catchTddErrorsAsEnvelope(Effect.gen(function* () {
@@ -192,4 +206,4 @@ const tddBehavior = idempotentProcedure.input(Schema.toStandardSchemaV1(TddBehav
192
206
  });
193
207
 
194
208
  //#endregion
195
- export { TddBehaviorResult, tddBehavior };
209
+ export { TDD_BEHAVIOR_ACTIONS, TddBehaviorResult, tddBehavior };
package/tools/tdd-goal.js CHANGED
@@ -101,6 +101,19 @@ const TddGoalInput = Schema.Union([
101
101
  GetVariant,
102
102
  ListVariant
103
103
  ]);
104
+ /**
105
+ * Single source of truth for the `tdd_goal` tool's `action`
106
+ * discriminant, consumed by `server.ts`'s served `z.enum(...)` so the
107
+ * MCP-SDK-side registration cannot drift from this tRPC input union
108
+ * (issue #335).
109
+ */
110
+ const TDD_GOAL_ACTIONS = [
111
+ "create",
112
+ "update",
113
+ "delete",
114
+ "get",
115
+ "list"
116
+ ];
104
117
  const tddGoal = idempotentProcedure.input(Schema.toStandardSchemaV1(TddGoalInput)).mutation(async ({ ctx, input }) => {
105
118
  return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
106
119
  create: (variant) => catchTddErrorsAsEnvelope(Effect.gen(function* () {
@@ -160,4 +173,4 @@ const tddGoal = idempotentProcedure.input(Schema.toStandardSchemaV1(TddGoalInput
160
173
  });
161
174
 
162
175
  //#endregion
163
- export { TddGoalResult, tddGoal };
176
+ export { TDD_GOAL_ACTIONS, TddGoalResult, tddGoal };
@@ -3,6 +3,12 @@ import { DataReader, DataStore, requiredArtifactForTransition, transitionEnforce
3
3
  import { Effect, Option, Schema } from "effect";
4
4
 
5
5
  //#region src/tools/tdd-phase-transition-request.ts
6
+ /**
7
+ * Lookback window for the missing_artifact_evidence cross-session
8
+ * diagnostic (issue #144). Wide enough to catch a subagent dispatch
9
+ * that ran for a few minutes before the agent hit the gate.
10
+ */
11
+ const DIAGNOSTIC_WINDOW_MINUTES = 10;
6
12
  const phaseLiteral = Schema.Literals([
7
13
  "spike",
8
14
  "red",
@@ -82,6 +88,7 @@ const tddPhaseTransitionRequest = publicProcedure.input(Schema.toStandardSchemaV
82
88
  const currentOpt = yield* reader.getCurrentTddPhase(input.tddTaskId);
83
89
  const currentPhase = Option.isSome(currentOpt) ? currentOpt.value.phase : "spike";
84
90
  const phaseStartedAt = Option.isSome(currentOpt) ? currentOpt.value.startedAt : (/* @__PURE__ */ new Date()).toISOString();
91
+ const currentPhaseId = Option.isSome(currentOpt) ? currentOpt.value.id : null;
85
92
  const goalOpt = yield* reader.getGoalById(input.goalId);
86
93
  if (Option.isNone(goalOpt)) return {
87
94
  accepted: false,
@@ -170,16 +177,24 @@ const tddPhaseTransitionRequest = publicProcedure.input(Schema.toStandardSchemaV
170
177
  ...scopeLookupByBehavior && { behaviorId: input.behaviorId },
171
178
  limit: 1
172
179
  });
173
- if (recent.length === 0) return {
174
- accepted: false,
175
- phase: currentPhase,
176
- denialReason: "missing_artifact_evidence",
177
- remediation: {
178
- suggestedTool: "run_tests",
179
- suggestedArgs: {},
180
- humanHint: `No '${kindToLookUp}' artifact has been recorded for tdd_task ${input.tddTaskId}. Artifacts are recorded by hooks observing your tool calls (Decision D7) — run the test (e.g. via run_tests) or make the file edit first; the post-tool-use hook will write the matching tdd_artifacts row and the next call to this tool will pick it up automatically.`
181
- }
182
- };
180
+ if (recent.length === 0) {
181
+ const sinceIso = (/* @__PURE__ */ new Date(Date.now() - DIAGNOSTIC_WINDOW_MINUTES * 6e4)).toISOString();
182
+ const crossSessionCount = yield* reader.countRecentArtifactsInOtherSessionsOfConversation({
183
+ tddTaskId: input.tddTaskId,
184
+ sinceIso
185
+ });
186
+ const baseHint = `No '${kindToLookUp}' artifact has been recorded for tdd_task ${input.tddTaskId}. Artifacts are recorded by hooks observing your tool calls (Decision D7) — run the test (e.g. via run_tests) or make the file edit first; the post-tool-use hook will write the matching tdd_artifacts row and the next call to this tool will pick it up automatically.`;
187
+ return {
188
+ accepted: false,
189
+ phase: currentPhase,
190
+ denialReason: "missing_artifact_evidence",
191
+ remediation: {
192
+ suggestedTool: "run_tests",
193
+ suggestedArgs: {},
194
+ humanHint: crossSessionCount > 0 ? `${baseHint} ${crossSessionCount} artifact${crossSessionCount === 1 ? " was" : "s were"} recorded under a different session of this conversation in the last ${DIAGNOSTIC_WINDOW_MINUTES} minutes — the subagent's hooks may be attributing to a detached session; set VITEST_AGENT_TDD_TASK_ID=${input.tddTaskId} in the subagent's environment or dispatch it as an unnamed background subagent.` : baseHint
195
+ }
196
+ };
197
+ }
183
198
  resolvedArtifactId = recent[0].id;
184
199
  }
185
200
  let citedArtifact;
@@ -205,11 +220,13 @@ const tddPhaseTransitionRequest = publicProcedure.input(Schema.toStandardSchemaV
205
220
  test_case_authored_in_session: false,
206
221
  test_run_id: null,
207
222
  test_first_failure_run_id: null,
208
- behavior_id: null
223
+ behavior_id: null,
224
+ suite: "vitest"
209
225
  };
210
226
  const result = validatePhaseTransition({
211
227
  tdd_task_id: input.tddTaskId,
212
228
  current_phase: currentPhase,
229
+ current_phase_id: currentPhaseId,
213
230
  phase_started_at: phaseStartedAt,
214
231
  now: (/* @__PURE__ */ new Date()).toISOString(),
215
232
  requested_phase: input.requestedPhase,
package/tools/tdd-task.js CHANGED
@@ -194,6 +194,18 @@ const TddTaskInput = Schema.Union([
194
194
  GetVariant,
195
195
  ResumeVariant
196
196
  ]);
197
+ /**
198
+ * Single source of truth for the `tdd_task` tool's `action`
199
+ * discriminant, consumed by `server.ts`'s served `z.enum(...)` so the
200
+ * MCP-SDK-side registration cannot drift from this tRPC input union
201
+ * (issue #335).
202
+ */
203
+ const TDD_TASK_ACTIONS = [
204
+ "start",
205
+ "end",
206
+ "get",
207
+ "resume"
208
+ ];
197
209
  const tddTask = idempotentProcedure.input(Schema.toStandardSchemaV1(TddTaskInput)).mutation(async ({ ctx, input }) => {
198
210
  return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
199
211
  start: (variant) => Effect.gen(function* () {
@@ -294,4 +306,4 @@ const tddTask = idempotentProcedure.input(Schema.toStandardSchemaV1(TddTaskInput
294
306
  });
295
307
 
296
308
  //#endregion
297
- export { TddTaskAsMarkdown, TddTaskResult, formatTddTaskMarkdown, tddTask };
309
+ export { TDD_TASK_ACTIONS, TddTaskAsMarkdown, TddTaskResult, formatTddTaskMarkdown, tddTask };
package/tools/test.js CHANGED
@@ -220,6 +220,17 @@ const TestInput = Schema.Union([
220
220
  ForFileVariant,
221
221
  ForTagVariant
222
222
  ]);
223
+ /**
224
+ * Single source of truth for the `test` tool's `action` discriminant,
225
+ * consumed by `server.ts`'s served `z.enum(...)` so the MCP-SDK-side
226
+ * registration cannot drift from this tRPC input union (issue #335).
227
+ */
228
+ const TEST_ACTIONS = [
229
+ "list",
230
+ "get",
231
+ "for_file",
232
+ "for_tag"
233
+ ];
223
234
  const test = publicProcedure.input(Schema.toStandardSchemaV1(TestInput)).query(async ({ ctx, input }) => {
224
235
  return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
225
236
  list: (variant) => Effect.gen(function* () {
@@ -310,4 +321,4 @@ const test = publicProcedure.input(Schema.toStandardSchemaV1(TestInput)).query(a
310
321
  });
311
322
 
312
323
  //#endregion
313
- export { TestAsMarkdown, TestResult, formatTestMarkdown, test };
324
+ export { TEST_ACTIONS, TestAsMarkdown, TestResult, formatTestMarkdown, test };