@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,73 @@
1
+ import { publicProcedure } from "../context.js";
2
+ import { DataReader } from "@vitest-agent/sdk";
3
+ import { Effect, Option, ParseResult, Schema } from "effect";
4
+
5
+ //#region src/tools/failure-signature-get.ts
6
+ /**
7
+ * `failure_signature_get` MCP tool — Schema-driven implementation.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ const RecentError = Schema.Struct({
12
+ runId: Schema.Number,
13
+ errorName: Schema.NullOr(Schema.String),
14
+ message: Schema.String
15
+ });
16
+ const SignatureFound = Schema.Struct({
17
+ found: Schema.Literal(true).annotations({ description: "Discriminant — `true` when a signature row matched." }),
18
+ signatureHash: Schema.String.annotations({
19
+ title: "failure_signatures.signature_hash",
20
+ description: "16-char SHA-256 over (error_name, normalized assertion shape, top-frame function name, function-boundary line)."
21
+ }),
22
+ firstSeenRunId: Schema.NullOr(Schema.Number),
23
+ firstSeenAt: Schema.String,
24
+ lastSeenAt: Schema.NullOr(Schema.String),
25
+ occurrenceCount: Schema.Number.annotations({ description: "Total times this signature has been observed." }),
26
+ recentErrors: Schema.Array(RecentError)
27
+ });
28
+ const SignatureMissing = Schema.Struct({
29
+ found: Schema.Literal(false),
30
+ requestedHash: Schema.String
31
+ });
32
+ const FailureSignatureGetResult = Schema.Union(SignatureFound, SignatureMissing).annotations({
33
+ identifier: "FailureSignatureGetResult",
34
+ title: "failure_signature_get result",
35
+ description: "Discriminate on `found`. Found rows carry first/last-seen timestamps and recent occurrences."
36
+ });
37
+ const formatFailureSignatureMarkdown = (data) => {
38
+ if (!data.found) return `No failure signature found with hash=${data.requestedHash}.`;
39
+ const lines = [
40
+ `# Failure Signature \`${data.signatureHash}\``,
41
+ "",
42
+ `**Hash:** ${data.signatureHash}`,
43
+ "",
44
+ `- first_seen_at: ${data.firstSeenAt}`,
45
+ `- last_seen_at: ${data.lastSeenAt ?? "unknown"}`,
46
+ `- first_seen_run_id: ${data.firstSeenRunId ?? "unknown"}`,
47
+ `- occurrence_count: ${data.occurrenceCount}`
48
+ ];
49
+ if (data.recentErrors.length > 0) {
50
+ lines.push("", "## Recent Errors", "");
51
+ for (const e of data.recentErrors) lines.push(`- run=${e.runId} name=${e.errorName ?? "(none)"}: ${e.message.slice(0, 120)}`);
52
+ }
53
+ return lines.join("\n");
54
+ };
55
+ const FailureSignatureGetAsMarkdown = Schema.transformOrFail(FailureSignatureGetResult, Schema.String, {
56
+ strict: true,
57
+ decode: (data) => ParseResult.succeed(formatFailureSignatureMarkdown(data)),
58
+ encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "FailureSignatureGetAsMarkdown is one-way."))
59
+ });
60
+ const failureSignatureGet = publicProcedure.input(Schema.standardSchemaV1(Schema.Struct({ hash: Schema.String }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
61
+ const opt = yield* (yield* DataReader).getFailureSignatureByHash(input.hash);
62
+ if (Option.isNone(opt)) return {
63
+ found: false,
64
+ requestedHash: input.hash
65
+ };
66
+ return {
67
+ found: true,
68
+ ...opt.value
69
+ };
70
+ })));
71
+
72
+ //#endregion
73
+ export { FailureSignatureGetAsMarkdown, FailureSignatureGetResult, failureSignatureGet };
@@ -0,0 +1,106 @@
1
+ import { publicProcedure } from "../context.js";
2
+ import { CoverageTotals, DataReader, FileCoverageReport } from "@vitest-agent/sdk";
3
+ import { Effect, Option, ParseResult, Schema } from "effect";
4
+
5
+ //#region src/tools/file-coverage.ts
6
+ /**
7
+ * `file_coverage` MCP tool — Schema-driven implementation.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ const CoverageGlobalThresholds = Schema.Struct({
12
+ statements: Schema.optional(Schema.Number),
13
+ branches: Schema.optional(Schema.Number),
14
+ functions: Schema.optional(Schema.Number),
15
+ lines: Schema.optional(Schema.Number)
16
+ });
17
+ const FileCoverageMatched = Schema.Struct({
18
+ dataAvailable: Schema.Literal(true),
19
+ matched: Schema.Literal(true),
20
+ filePath: Schema.String,
21
+ report: FileCoverageReport,
22
+ globalThresholds: CoverageGlobalThresholds,
23
+ relatedTestFiles: Schema.Array(Schema.String)
24
+ }).annotations({ identifier: "FileCoverageMatched" });
25
+ const FileCoverageNoMatch = Schema.Struct({
26
+ dataAvailable: Schema.Literal(true),
27
+ matched: Schema.Literal(false),
28
+ filePath: Schema.String,
29
+ totals: CoverageTotals,
30
+ relatedTestFiles: Schema.Array(Schema.String)
31
+ }).annotations({ identifier: "FileCoverageNoMatch" });
32
+ const FileCoverageAbsent = Schema.Struct({
33
+ dataAvailable: Schema.Literal(false),
34
+ filePath: Schema.String
35
+ }).annotations({ identifier: "FileCoverageAbsent" });
36
+ const FileCoverageResult = Schema.Union(FileCoverageMatched, FileCoverageNoMatch, FileCoverageAbsent).annotations({
37
+ identifier: "FileCoverageResult",
38
+ title: "file_coverage result",
39
+ description: "Per-file coverage with related tests. Discriminate on `dataAvailable` then on `matched`."
40
+ });
41
+ const formatFileCoverageMarkdown = (data) => {
42
+ if (!data.dataAvailable) return "No coverage data available. Run tests with coverage enabled.";
43
+ const lines = [`# Coverage: \`${data.filePath}\``, ""];
44
+ const metrics = [
45
+ "statements",
46
+ "branches",
47
+ "functions",
48
+ "lines"
49
+ ];
50
+ if (data.matched) {
51
+ lines.push("## Metrics", "", "| Metric | Value | Threshold |", "| --- | --- | --- |");
52
+ for (const metric of metrics) {
53
+ const value = data.report.summary[metric];
54
+ const threshold = data.globalThresholds[metric];
55
+ const thresholdStr = threshold !== void 0 ? `${threshold}%` : "—";
56
+ const icon = threshold !== void 0 && value < threshold ? "❌" : "✅";
57
+ lines.push(`| ${metric} | ${icon} ${value.toFixed(2)}% | ${thresholdStr} |`);
58
+ }
59
+ if (data.report.uncoveredLines) lines.push("", "## Uncovered Lines", "", `\`${data.report.uncoveredLines}\``);
60
+ lines.push("", "## Next steps", "", "- Use test({ action: \"for_file\" }) to find tests covering this file", "- Write tests targeting the uncovered lines");
61
+ } else lines.push("This file is not in the low-coverage list.", "", "Possible reasons:", "- File meets all coverage thresholds", "- File was not included in the coverage run", "- File path does not match any tracked source file", "", "## Project Coverage Totals", "", "| Metric | Value |", "| --- | --- |", `| statements | ${data.totals.statements.toFixed(2)}% |`, `| branches | ${data.totals.branches.toFixed(2)}% |`, `| functions | ${data.totals.functions.toFixed(2)}% |`, `| lines | ${data.totals.lines.toFixed(2)}% |`);
62
+ if (data.relatedTestFiles.length > 0) {
63
+ lines.push("", "## Tests Covering This File", "");
64
+ for (const tf of data.relatedTestFiles) lines.push(`- \`${tf}\``);
65
+ }
66
+ return lines.join("\n");
67
+ };
68
+ const FileCoverageAsMarkdown = Schema.transformOrFail(FileCoverageResult, Schema.String, {
69
+ strict: true,
70
+ decode: (data) => ParseResult.succeed(formatFileCoverageMarkdown(data)),
71
+ encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "FileCoverageAsMarkdown is one-way."))
72
+ });
73
+ const fileCoverage = publicProcedure.input(Schema.standardSchemaV1(Schema.Struct({
74
+ filePath: Schema.String,
75
+ project: Schema.optional(Schema.String)
76
+ }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
77
+ const reader = yield* DataReader;
78
+ const project = input.project ?? "default";
79
+ const coverageOpt = yield* reader.getCoverage(project);
80
+ if (Option.isNone(coverageOpt)) return {
81
+ dataAvailable: false,
82
+ filePath: input.filePath
83
+ };
84
+ const coverage = coverageOpt.value;
85
+ const normalizedPath = input.filePath.replace(/^\.\//, "");
86
+ const match = coverage.lowCoverage.find((f) => f.file === normalizedPath) ?? coverage.lowCoverage.find((f) => f.file.endsWith(normalizedPath) || normalizedPath.endsWith(f.file));
87
+ const relatedTestFiles = yield* reader.getTestsForFile(normalizedPath);
88
+ if (match) return {
89
+ dataAvailable: true,
90
+ matched: true,
91
+ filePath: normalizedPath,
92
+ report: match,
93
+ globalThresholds: coverage.thresholds.global,
94
+ relatedTestFiles
95
+ };
96
+ return {
97
+ dataAvailable: true,
98
+ matched: false,
99
+ filePath: normalizedPath,
100
+ totals: coverage.totals,
101
+ relatedTestFiles
102
+ };
103
+ })));
104
+
105
+ //#endregion
106
+ export { FileCoverageAsMarkdown, FileCoverageResult, fileCoverage };
package/tools/help.js ADDED
@@ -0,0 +1,146 @@
1
+ import { publicProcedure } from "../context.js";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/tools/help.ts
5
+ const HelpResult = Schema.Struct({ helpText: Schema.String.annotations({ description: "Markdown table of every MCP tool with parameters and a one-line description." }) }).annotations({
6
+ identifier: "HelpResult",
7
+ title: "help result",
8
+ description: "Static help reference. Read structuredContent.helpText programmatically; the same string lives in content[].text for transcripts."
9
+ });
10
+ const HELP_TEXT = `# vitest-agent MCP Tools
11
+
12
+ > Consolidated tool surface (Phase 3 of the agent-agnostic taxonomy).
13
+ > Action-keyed tools collapse the prior 5–6 CRUD families into one tool
14
+ > per noun: \`hypothesis\`, \`note\`, \`inventory\`, \`test\`,
15
+ > \`tdd_goal\`, \`tdd_behavior\`, \`tdd_task\`.
16
+
17
+ ## General
18
+
19
+ | Tool | Parameters | Description |
20
+ | ---- | ---------- | ----------- |
21
+ | \`help\` | _(none)_ | List all available MCP tools with parameters |
22
+ | \`ping\` | _(none)_ | Ping the MCP server — returns 'pong'. Used to verify hot-patch reload |
23
+
24
+ ## Test Data (read-only)
25
+
26
+ | Tool | Parameters | Description |
27
+ | ---- | ---------- | ----------- |
28
+ | \`test_status\` | \`project?\` | Per-project test pass/fail state |
29
+ | \`test_overview\` | \`project?\` | Test landscape with run metrics |
30
+ | \`test_coverage\` | \`project?\` | Coverage gaps with uncovered lines |
31
+ | \`file_coverage\` | \`filePath\`, \`project?\` | Per-file coverage with uncovered lines and related tests |
32
+ | \`test_history\` | \`project\` | Flaky/persistent/recovered tests |
33
+ | \`test_trends\` | \`project\`, \`limit?\` | Coverage trajectory over time |
34
+ | \`test_errors\` | \`project\`, \`errorName?\`, \`format?\` (\`markdown\` \\| \`xml\`) | Errors with diffs, stacks, and the cite-able \`testErrorId\` / \`topStackFrameId\` values needed by \`hypothesis (action: record)\` |
35
+ | \`test\` | \`action\` (\`list\`/\`get\`/\`for_file\`/\`for_tag\`), plus per-action params | Consolidated test inspection: list/get/for_file/for_tag |
36
+
37
+ \`test\` actions:
38
+ - \`{ action: "list", project?, state?, module?, limit? }\`
39
+ - \`{ action: "get", fullName, project? }\`
40
+ - \`{ action: "for_file", filePath }\`
41
+ - \`{ action: "for_tag", tag, project? }\` — list every test carrying a tag, grouped by project (or one group when project is supplied)
42
+
43
+ ## Discovery
44
+
45
+ | Tool | Parameters | Description |
46
+ | ---- | ---------- | ----------- |
47
+ | \`inventory\` | \`kind\` (\`project\`/\`module\`/\`suite\`/\`session\`/\`tag\`), plus per-kind params | Consolidated entity discovery |
48
+ | \`settings_list\` | _(none)_ | Vitest config snapshots |
49
+
50
+ \`inventory\` kinds:
51
+ - \`{ kind: "project" }\`
52
+ - \`{ kind: "module", project? }\`
53
+ - \`{ kind: "suite", project?, module? }\`
54
+ - \`{ kind: "session", project?, agentKind?, limit? }\` (omit \`id\` to list)
55
+ - \`{ kind: "session", id }\` (single-session detail)
56
+ - \`{ kind: "tag", project? }\` — per-tag module/test counts; unscoped form carries a \`byProject\` breakdown per tag
57
+
58
+ ## Execution
59
+
60
+ | Tool | Parameters | Description |
61
+ | ---- | ---------- | ----------- |
62
+ | \`run_tests\` | \`files?\`, \`project?\`, \`tags?\`, \`passWithNoTests?\`, \`timeout?\`, \`format?\` | Run vitest with optional filters. \`tags\` is a structured \`{ all?, any?, none? }\` filter that intersects with \`project\` and \`files\` via AND. When the resolved filter set matches zero test cases the result discriminator is \`no-match\` (carrying the resolved filter context) rather than \`ok\`. \`passWithNoTests\` is a per-call override of Vitest's project-level \`test.passWithNoTests\` policy |
63
+ | \`register_agent\` | \`sessionId\`, \`agentType\`, \`hostKind?\`, \`parentAgentId?\`, \`clientNonce?\`, \`startGitBranch?\`, \`startGitCommitSha?\`, \`startWorktreeDir?\` | Idempotent agent invocation registration |
64
+
65
+ ## Diagnostics
66
+
67
+ | Tool | Parameters | Description |
68
+ | ---- | ---------- | ----------- |
69
+ | \`cache_health\` | _(none)_ | Database health and staleness check |
70
+ | \`configure\` | \`settingsHash?\` | View captured Vitest settings |
71
+ | \`failure_signature_get\` | \`hash\` | Stable failure signature with recent example errors |
72
+ | \`turn_search\` | \`sessionId?\`, \`since?\`, \`type?\`, \`limit?\` | Search turns (default limit 100) |
73
+ | \`acceptance_metrics\` | _(none)_ | Four spec Annex A acceptance metrics |
74
+ | \`triage_brief\` | \`project?\`, \`maxLines?\` | Orientation triage |
75
+ | \`wrapup_prompt\` | \`sessionId?\`, \`chatId?\`, \`kind?\`, \`userPromptHint?\` | Tailored wrap-up prompt |
76
+ | \`commit_changes\` | \`sha?\` | Commit metadata + changed files |
77
+
78
+ ## Notes
79
+
80
+ | Tool | Parameters | Description |
81
+ | ---- | ---------- | ----------- |
82
+ | \`note\` | \`action\` (\`create\`/\`list\`/\`get\`/\`update\`/\`delete\`/\`search\`), plus per-action params | Consolidated note CRUD with FTS5 search |
83
+
84
+ \`note\` actions:
85
+ - \`{ action: "create", title, content, scope, project?, testFullName?, modulePath?, parentNoteId?, createdBy?, expiresAt?, pinned? }\`
86
+ - \`{ action: "list", scope?, project?, testFullName? }\`
87
+ - \`{ action: "get", id }\`
88
+ - \`{ action: "update", id, title?, content?, pinned?, expiresAt? }\`
89
+ - \`{ action: "delete", id }\`
90
+ - \`{ action: "search", query }\`
91
+
92
+ ## Hypotheses
93
+
94
+ | Tool | Parameters | Description |
95
+ | ---- | ---------- | ----------- |
96
+ | \`hypothesis\` | \`action\` (\`record\`/\`validate\`/\`list\`), plus per-action params | Consolidated hypothesis surface |
97
+
98
+ \`hypothesis\` actions:
99
+ - \`{ action: "record", sessionId, content, createdTurnId?, citedTestErrorId?, citedStackFrameId? }\`
100
+ - \`{ action: "validate", id, outcome, validatedAt, validatedTurnId? }\`
101
+ - \`{ action: "list", sessionId?, outcome?, limit? }\`
102
+
103
+ ## TDD lifecycle
104
+
105
+ | Tool | Parameters | Description |
106
+ | ---- | ---------- | ----------- |
107
+ | \`tdd_task\` | \`action\` (\`start\`/\`end\`/\`get\`/\`resume\`), plus per-action params | TDD task lifecycle |
108
+ | \`tdd_goal\` | \`action\` (\`create\`/\`update\`/\`delete\`/\`get\`/\`list\`), plus per-action params | Goals under a TDD task |
109
+ | \`tdd_behavior\` | \`action\` (\`create\`/\`update\`/\`delete\`/\`get\`/\`list_by_goal\`/\`list_by_tdd_task\`), plus per-action params | Behaviors under a goal |
110
+ | \`tdd_artifact_list\` | \`tddTaskId\`, \`artifactKind?\`, \`phaseId?\`, \`behaviorId?\`, \`limit?\`, \`format?\` | List recorded TDD artifacts (newest first); use to find the artifact id to cite in \`tdd_phase_transition_request\` |
111
+ | \`tdd_phase_transition_request\` | \`tddTaskId\`, \`goalId\`, \`requestedPhase\`, \`citedArtifactId?\`, \`citedArtifactKind?\`, \`behaviorId?\`, \`reason?\` | Request a phase transition; validates D2 binding rules. \`citedArtifactId\` is optional — when omitted, the most recent matching artifact (kind from \`citedArtifactKind\` or the transition's required-evidence rule) is auto-resolved |
112
+ | \`tdd_progress_push\` | \`payload\` | Push a TDD progress event to the main agent (best-effort) |
113
+
114
+ \`tdd_task\` actions:
115
+ - \`{ action: "start", goal, sessionId|chatId, parentTddTaskId?, startedAt?, runId? }\`
116
+ - \`{ action: "end", tddTaskId, outcome, summaryNoteId? }\`
117
+ - \`{ action: "get", tddTaskId }\`
118
+ - \`{ action: "resume", tddTaskId }\`
119
+
120
+ \`tdd_goal\` actions:
121
+ - \`{ action: "create", tddTaskId, goal }\`
122
+ - \`{ action: "update", id, goal?, status? }\`
123
+ - \`{ action: "delete", id }\`
124
+ - \`{ action: "get", id }\`
125
+ - \`{ action: "list", tddTaskId }\`
126
+
127
+ \`tdd_behavior\` actions:
128
+ - \`{ action: "create", goalId, behavior, suggestedTestName?, dependsOnBehaviorIds? }\`
129
+ - \`{ action: "update", id, behavior?, suggestedTestName?, status?, dependsOnBehaviorIds? }\`
130
+ - \`{ action: "delete", id }\`
131
+ - \`{ action: "get", id }\`
132
+ - \`{ action: "list_by_goal", goalId }\`
133
+ - \`{ action: "list_by_tdd_task", tddTaskId }\`
134
+
135
+ ## Parameter Key
136
+
137
+ - **Required** parameters are unmarked
138
+ - **Optional** parameters have \`?\` suffix
139
+ - \`project\` filters to a Vitest project name
140
+ - \`state\` accepts: \`passed\`, \`failed\`, \`skipped\`, \`pending\`
141
+ - \`scope\` accepts: \`global\`, \`project\`, \`module\`, \`suite\`, \`test\`, \`note\`
142
+ `;
143
+ const help = publicProcedure.query(() => ({ helpText: HELP_TEXT }));
144
+
145
+ //#endregion
146
+ export { HelpResult, help };
@@ -0,0 +1,121 @@
1
+ import { publicProcedure } from "../context.js";
2
+ import { DataReader, HistoryRecord } from "@vitest-agent/sdk";
3
+ import { Effect, ParseResult, Schema } from "effect";
4
+
5
+ //#region src/tools/history.ts
6
+ /**
7
+ * `test_history` MCP tool — Schema-driven implementation.
8
+ *
9
+ * The structured payload bundles the underlying `HistoryRecord` plus
10
+ * the lighter `flaky` / `persistent` projections the UI uses, so an
11
+ * agent doesn't need to recompute them from runs[].
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+ const FlakyTestRow = Schema.Struct({
16
+ fullName: Schema.String.annotations({ description: "Full hierarchical test name (`describe > it`)." }),
17
+ project: Schema.String,
18
+ passCount: Schema.Number.annotations({ description: "Number of passing runs in the recent window." }),
19
+ failCount: Schema.Number.annotations({ description: "Number of failing runs in the recent window." }),
20
+ lastState: Schema.Literal("passed", "failed").annotations({ description: "State of the most recent run." }),
21
+ lastTimestamp: Schema.String.annotations({ description: "ISO-8601 timestamp of the most recent run." })
22
+ }).annotations({
23
+ identifier: "FlakyTestRow",
24
+ description: "A test that produced both passes and failures within the recent run window."
25
+ });
26
+ const PersistentFailureRow = Schema.Struct({
27
+ fullName: Schema.String,
28
+ project: Schema.String,
29
+ consecutiveFailures: Schema.Number.annotations({ description: "Length of the current uninterrupted failure streak." }),
30
+ firstFailedAt: Schema.String.annotations({ description: "ISO-8601 timestamp of the first failure in this streak." }),
31
+ lastFailedAt: Schema.String.annotations({ description: "ISO-8601 timestamp of the most recent failure." }),
32
+ lastErrorMessage: Schema.NullOr(Schema.String).annotations({ description: "Last error message reported by the failing test, when captured." })
33
+ }).annotations({
34
+ identifier: "PersistentFailureRow",
35
+ description: "A test that has failed in every recent run since `firstFailedAt`."
36
+ });
37
+ const RecoveredTestRow = Schema.Struct({
38
+ fullName: Schema.String,
39
+ recentRuns: Schema.Array(Schema.Literal("passed", "failed")).annotations({ description: "Last 10 run states for this test, oldest first." })
40
+ }).annotations({
41
+ identifier: "RecoveredTestRow",
42
+ description: "A test whose latest run passed after the previous one failed."
43
+ });
44
+ const TestHistoryResult = Schema.Struct({
45
+ project: Schema.String.annotations({ description: "Workspace project key the history was computed for." }),
46
+ hasData: Schema.Boolean.annotations({ description: "`false` when no history rows exist for the project — agent should suggest running tests first." }),
47
+ history: HistoryRecord.annotations({ description: "Raw per-test history record (stored in `test_runs` joins)." }),
48
+ flaky: Schema.Array(FlakyTestRow).annotations({ description: "Tests with mixed pass/fail outcomes recently." }),
49
+ persistent: Schema.Array(PersistentFailureRow).annotations({ description: "Tests failing across consecutive runs." }),
50
+ recovered: Schema.Array(RecoveredTestRow).annotations({ description: "Tests that just transitioned from failing to passing in the last run." })
51
+ }).annotations({
52
+ identifier: "TestHistoryResult",
53
+ title: "test_history result",
54
+ description: "Per-project flaky/persistent/recovered test classifications computed from `test_runs` history."
55
+ });
56
+ const formatTestHistoryMarkdown = (data) => {
57
+ if (!data.hasData) return `No history data available for project \`${data.project}\`. Run tests first.`;
58
+ const lines = [`# Test History: ${data.project}`, ""];
59
+ if (data.flaky.length > 0) {
60
+ lines.push("## Flaky Tests", "", "Tests with mixed pass/fail results across recent runs:", "");
61
+ for (const test of data.flaky) {
62
+ const total = test.passCount + test.failCount;
63
+ const passRate = total > 0 ? (test.passCount / total * 100).toFixed(0) : "0";
64
+ lines.push(`### ⚠️ ${test.fullName}`, "", `- Pass rate: ${passRate}% (${test.passCount}/${total})`, `- Last state: ${test.lastState}`, `- Last run: ${new Date(test.lastTimestamp).toLocaleString()}`, "");
65
+ }
66
+ }
67
+ if (data.persistent.length > 0) {
68
+ lines.push("## Persistent Failures", "", "Tests that have failed in consecutive runs:", "");
69
+ for (const failure of data.persistent) {
70
+ lines.push(`### ❌ ${failure.fullName}`, "", `- Consecutive failures: ${failure.consecutiveFailures}`, `- First failed: ${new Date(failure.firstFailedAt).toLocaleString()}`, `- Last failed: ${new Date(failure.lastFailedAt).toLocaleString()}`);
71
+ if (failure.lastErrorMessage !== null) lines.push(`- Last error: ${failure.lastErrorMessage}`);
72
+ lines.push("");
73
+ }
74
+ }
75
+ if (data.recovered.length > 0) {
76
+ lines.push("## Recovered Tests", "", "Tests that previously failed but are now passing:", "");
77
+ for (const test of data.recovered) {
78
+ const runViz = test.recentRuns.map((s) => s === "passed" ? "P" : "F").join("");
79
+ lines.push(`- ✅ **${test.fullName}** — recent runs: \`${runViz}\``);
80
+ }
81
+ lines.push("");
82
+ }
83
+ if (data.flaky.length === 0 && data.persistent.length === 0 && data.recovered.length === 0) lines.push("✅ No flaky, persistent, or recently recovered tests.", "");
84
+ lines.push(`_History updated: ${data.history.updatedAt}_`);
85
+ return lines.join("\n");
86
+ };
87
+ const TestHistoryAsMarkdown = Schema.transformOrFail(TestHistoryResult, Schema.String, {
88
+ strict: true,
89
+ decode: (data) => ParseResult.succeed(formatTestHistoryMarkdown(data)),
90
+ encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "TestHistoryAsMarkdown is one-way: markdown cannot be parsed back to TestHistoryResult."))
91
+ });
92
+ const testHistory = publicProcedure.input(Schema.standardSchemaV1(Schema.Struct({ project: Schema.String }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
93
+ const reader = yield* DataReader;
94
+ const [history, flaky, persistent] = yield* Effect.all([
95
+ reader.getHistory(input.project),
96
+ reader.getFlaky(input.project),
97
+ reader.getPersistentFailures(input.project)
98
+ ]);
99
+ const recovered = history.tests.filter((t) => {
100
+ const runs = t.runs;
101
+ if (runs.length < 2) return false;
102
+ const last = runs[runs.length - 1];
103
+ const prev = runs[runs.length - 2];
104
+ return last !== void 0 && prev !== void 0 && last.state === "passed" && prev.state === "failed";
105
+ }).map((t) => ({
106
+ fullName: t.fullName,
107
+ recentRuns: t.runs.slice(-10).map((r) => r.state)
108
+ }));
109
+ const hasData = history.tests.length > 0 || flaky.length > 0 || persistent.length > 0;
110
+ return {
111
+ project: input.project,
112
+ hasData,
113
+ history,
114
+ flaky,
115
+ persistent,
116
+ recovered
117
+ };
118
+ })));
119
+
120
+ //#endregion
121
+ export { TestHistoryAsMarkdown, TestHistoryResult, testHistory };
@@ -0,0 +1,127 @@
1
+ import { idempotentProcedure } from "../middleware/idempotency.js";
2
+ import { DataReader, DataStore, DataStoreError } from "@vitest-agent/sdk";
3
+ import { Effect, Match, Option, Schema } from "effect";
4
+
5
+ //#region src/tools/hypothesis.ts
6
+ /**
7
+ * Consolidated `hypothesis` MCP tool — Schema-driven implementation.
8
+ *
9
+ * `record` and `validate` are mutations whose result is a small
10
+ * structured envelope. `list` now returns a structured array; the
11
+ * boundary in server.ts renders it as markdown via the exported
12
+ * `formatHypothesisListMarkdown` helper.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ const HypothesisRowSchema = Schema.Struct({
17
+ id: Schema.Number,
18
+ sessionId: Schema.Number,
19
+ content: Schema.String.annotations({ description: "Free-text hypothesis the agent recorded before attempting a fix." }),
20
+ citedTestErrorId: Schema.NullOr(Schema.Number).annotations({ description: "Optional `test_errors.id` the hypothesis cites as the failing observation." }),
21
+ citedStackFrameId: Schema.NullOr(Schema.Number).annotations({ description: "Optional `stack_frames.id` for the specific frame the hypothesis blames." }),
22
+ validationOutcome: Schema.NullOr(Schema.Literal("confirmed", "refuted", "abandoned")).annotations({ description: "Outcome recorded by `hypothesis (action: validate)`; `null` while still open." }),
23
+ validatedAt: Schema.NullOr(Schema.String).annotations({ description: "ISO-8601 validation timestamp; `null` while open." })
24
+ }).annotations({ identifier: "HypothesisRow" });
25
+ const HypothesisRecordOk = Schema.Struct({
26
+ action: Schema.Literal("record"),
27
+ id: Schema.Number.annotations({ description: "Newly inserted hypothesis row primary key." })
28
+ });
29
+ const HypothesisValidateOk = Schema.Struct({ action: Schema.Literal("validate") });
30
+ const HypothesisListOk = Schema.Struct({
31
+ action: Schema.Literal("list"),
32
+ count: Schema.Number,
33
+ hypotheses: Schema.Array(HypothesisRowSchema)
34
+ });
35
+ const HypothesisResult = Schema.Union(HypothesisRecordOk, HypothesisValidateOk, HypothesisListOk).annotations({
36
+ identifier: "HypothesisResult",
37
+ title: "hypothesis result",
38
+ description: "Discriminate on `action`. record returns the new id; list returns the matching rows; validate returns an empty acknowledgement."
39
+ });
40
+ const formatHypothesisListMarkdown = (data) => {
41
+ if (data.action !== "list") return JSON.stringify(data, null, 2);
42
+ if (data.hypotheses.length === 0) return "No hypotheses matched.";
43
+ const lines = ["# Hypotheses", ""];
44
+ for (const h of data.hypotheses) {
45
+ const status = h.validationOutcome ?? "open";
46
+ lines.push(`- [${status}] id=${h.id} session=${h.sessionId}: ${h.content.slice(0, 120)}`);
47
+ }
48
+ return lines.join("\n");
49
+ };
50
+ const RecordVariant = Schema.Struct({
51
+ action: Schema.Literal("record"),
52
+ sessionId: Schema.optional(Schema.Number),
53
+ content: Schema.String,
54
+ createdTurnId: Schema.optional(Schema.Number),
55
+ citedTestErrorId: Schema.optional(Schema.Number),
56
+ citedStackFrameId: Schema.optional(Schema.Number)
57
+ });
58
+ const ValidateVariant = Schema.Struct({
59
+ action: Schema.Literal("validate"),
60
+ id: Schema.Number,
61
+ outcome: Schema.Literal("confirmed", "refuted", "abandoned"),
62
+ validatedTurnId: Schema.optional(Schema.Number),
63
+ validatedAt: Schema.String
64
+ });
65
+ const ListVariant = Schema.Struct({
66
+ action: Schema.Literal("list"),
67
+ sessionId: Schema.optional(Schema.Number),
68
+ outcome: Schema.optional(Schema.Literal("confirmed", "refuted", "abandoned", "open")),
69
+ limit: Schema.optional(Schema.Number)
70
+ });
71
+ const HypothesisInput = Schema.Union(RecordVariant, ValidateVariant, ListVariant);
72
+ const hypothesis = idempotentProcedure.input(Schema.standardSchemaV1(HypothesisInput)).mutation(async ({ ctx, input }) => {
73
+ return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
74
+ record: (variant) => Effect.gen(function* () {
75
+ const store = yield* DataStore;
76
+ const reader = yield* DataReader;
77
+ const sc = ctx.sessionContext.get();
78
+ let resolvedSessionId = variant.sessionId;
79
+ if (sc !== null) {
80
+ const main = yield* reader.getSessionByChatId(sc.chatId);
81
+ if (Option.isSome(main)) {
82
+ const sub = yield* reader.findActiveSubagentSession(main.value.id);
83
+ resolvedSessionId = Option.isSome(sub) ? sub.value.id : main.value.id;
84
+ }
85
+ }
86
+ if (resolvedSessionId === void 0) return yield* Effect.fail(new DataStoreError({
87
+ operation: "write",
88
+ table: "hypotheses",
89
+ reason: "no recovered session context and no sessionId supplied to attribute hypothesis"
90
+ }));
91
+ return {
92
+ action: "record",
93
+ id: yield* store.writeHypothesis({
94
+ sessionId: resolvedSessionId,
95
+ content: variant.content,
96
+ ...variant.createdTurnId !== void 0 && { createdTurnId: variant.createdTurnId },
97
+ ...variant.citedTestErrorId !== void 0 && { citedTestErrorId: variant.citedTestErrorId },
98
+ ...variant.citedStackFrameId !== void 0 && { citedStackFrameId: variant.citedStackFrameId }
99
+ })
100
+ };
101
+ }),
102
+ validate: (variant) => Effect.gen(function* () {
103
+ yield* (yield* DataStore).validateHypothesis({
104
+ id: variant.id,
105
+ outcome: variant.outcome,
106
+ validatedAt: variant.validatedAt,
107
+ ...variant.validatedTurnId !== void 0 && { validatedTurnId: variant.validatedTurnId }
108
+ });
109
+ return { action: "validate" };
110
+ }),
111
+ list: (variant) => Effect.gen(function* () {
112
+ const rows = yield* (yield* DataReader).listHypotheses({
113
+ ...variant.sessionId !== void 0 && { sessionId: variant.sessionId },
114
+ ...variant.outcome !== void 0 && { outcome: variant.outcome },
115
+ ...variant.limit !== void 0 && { limit: variant.limit }
116
+ });
117
+ return {
118
+ action: "list",
119
+ count: rows.length,
120
+ hypotheses: rows
121
+ };
122
+ })
123
+ })));
124
+ });
125
+
126
+ //#endregion
127
+ export { HypothesisResult, formatHypothesisListMarkdown, hypothesis };