@vitest-agent/mcp 3.0.4 → 4.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 (54) hide show
  1. package/README.md +7 -6
  2. package/annotations.js +18 -0
  3. package/bin/vitest-agent-mcp.js +5 -140
  4. package/{middleware/idempotency.js → idempotency.js} +48 -49
  5. package/index.d.ts +5610 -1549
  6. package/index.js +35 -17
  7. package/main.d.ts +31 -0
  8. package/main.js +144 -0
  9. package/package.json +9 -6
  10. package/prompts/layer.js +108 -0
  11. package/register-toolkit.js +311 -0
  12. package/server.js +24 -894
  13. package/{context.js → session.js} +38 -22
  14. package/toolkit.js +86 -0
  15. package/tools/acceptance-metrics.js +26 -14
  16. package/tools/cache-health.js +28 -17
  17. package/tools/commit-changes.js +33 -10
  18. package/tools/configure.js +33 -10
  19. package/tools/coverage.js +34 -5
  20. package/tools/errors.js +36 -26
  21. package/tools/failure-signature-get.js +33 -10
  22. package/tools/file-coverage.js +37 -13
  23. package/tools/help.js +45 -4
  24. package/tools/history.js +39 -19
  25. package/tools/hypothesis.js +118 -102
  26. package/tools/inventory.js +149 -146
  27. package/tools/note.js +131 -113
  28. package/tools/overview.js +33 -10
  29. package/tools/ping.js +22 -10
  30. package/tools/register-agent.js +88 -62
  31. package/tools/run-tests.js +76 -23
  32. package/tools/settings-list.js +27 -10
  33. package/tools/status.js +35 -10
  34. package/tools/tdd-artifact.js +37 -22
  35. package/tools/tdd-behavior.js +120 -108
  36. package/tools/tdd-goal.js +98 -85
  37. package/tools/tdd-phase-transition-request.js +201 -166
  38. package/tools/tdd-progress-push.js +102 -0
  39. package/tools/tdd-task.js +140 -138
  40. package/tools/test.js +152 -138
  41. package/tools/trends.js +37 -18
  42. package/tools/triage-brief.js +34 -15
  43. package/tools/turn-search.js +39 -16
  44. package/tools/wrapup-prompt.js +37 -17
  45. package/utils/crash-guards.js +0 -22
  46. package/utils/replay-marker.js +12 -0
  47. package/utils/safe-format-fatal-error.js +0 -16
  48. package/utils/tool-error-envelope.js +3 -3
  49. package/version.js +13 -0
  50. package/layers/McpLive.js +0 -29
  51. package/prompts/index.js +0 -89
  52. package/router.js +0 -74
  53. package/session-env.js +0 -112
  54. package/utils/effect-to-zod.js +0 -158
@@ -1,13 +1,9 @@
1
- import { publicProcedure } from "../context.js";
1
+ import { RenderText } from "../annotations.js";
2
2
  import { Effect, Option, Schema, SchemaGetter } from "effect";
3
- import { DataReader } from "@vitest-agent/sdk";
3
+ import { DataReader } from "@vitest-agent/engine";
4
+ import { Tool } from "effect/unstable/ai";
4
5
 
5
6
  //#region src/tools/failure-signature-get.ts
6
- /**
7
- * `failure_signature_get` MCP tool — Schema-driven implementation.
8
- *
9
- * @packageDocumentation
10
- */
11
7
  const RecentError = Schema.Struct({
12
8
  runId: Schema.Number,
13
9
  errorName: Schema.NullOr(Schema.String),
@@ -29,6 +25,11 @@ const SignatureMissing = Schema.Struct({
29
25
  found: Schema.Literal(false),
30
26
  requestedHash: Schema.String
31
27
  });
28
+ /**
29
+ * The `failure_signature_get` tool's success payload.
30
+ *
31
+ * @public
32
+ */
32
33
  const FailureSignatureGetResult = Schema.Union([SignatureFound, SignatureMissing]).annotate({
33
34
  identifier: "FailureSignatureGetResult",
34
35
  title: "failure_signature_get result",
@@ -56,7 +57,18 @@ const FailureSignatureGetAsMarkdown = FailureSignatureGetResult.pipe(Schema.deco
56
57
  decode: SchemaGetter.transform((data) => formatFailureSignatureMarkdown(data)),
57
58
  encode: SchemaGetter.forbidden(() => "FailureSignatureGetAsMarkdown is one-way.")
58
59
  }));
59
- const failureSignatureGet = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({ hash: Schema.String }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
60
+ /**
61
+ * The `failure_signature_get` tool's parameters.
62
+ *
63
+ * @public
64
+ */
65
+ const FailureSignatureGetInput = Schema.Struct({ hash: Schema.String.annotate({ description: "16-char failure signature hash" }) });
66
+ /**
67
+ * Handler for {@link failureSignatureGetTool}.
68
+ *
69
+ * @public
70
+ */
71
+ const handleFailureSignatureGet = (input) => Effect.gen(function* () {
60
72
  const opt = yield* (yield* DataReader).getFailureSignatureByHash(input.hash);
61
73
  if (Option.isNone(opt)) return {
62
74
  found: false,
@@ -66,7 +78,18 @@ const failureSignatureGet = publicProcedure.input(Schema.toStandardSchemaV1(Sche
66
78
  found: true,
67
79
  ...opt.value
68
80
  };
69
- })));
81
+ }).pipe(Effect.orDie);
82
+ /**
83
+ * The Effect-native `failure_signature_get` tool.
84
+ *
85
+ * @public
86
+ */
87
+ const failureSignatureGetTool = Tool.make("failure_signature_get", {
88
+ description: "Use when you have a failure-signature hash and need its first-seen date and occurrence history. Returns markdown in content[] and a typed JSON object in structuredContent ({ found, signatureHash?, firstSeenAt?, occurrenceCount?, recentErrors?[] } or absent variant).",
89
+ parameters: FailureSignatureGetInput,
90
+ success: FailureSignatureGetResult,
91
+ dependencies: [DataReader]
92
+ }).annotate(Tool.Title, "Failure signature").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true).annotate(RenderText, (encoded) => formatFailureSignatureMarkdown(encoded));
70
93
 
71
94
  //#endregion
72
- export { FailureSignatureGetAsMarkdown, FailureSignatureGetResult, failureSignatureGet, formatFailureSignatureMarkdown };
95
+ export { FailureSignatureGetInput, FailureSignatureGetResult, failureSignatureGetTool, formatFailureSignatureMarkdown, handleFailureSignatureGet };
@@ -1,13 +1,10 @@
1
- import { publicProcedure } from "../context.js";
1
+ import { RenderText } from "../annotations.js";
2
2
  import { Effect, Option, Schema, SchemaGetter } from "effect";
3
- import { CoverageTotals, DataReader, FileCoverageReport } from "@vitest-agent/sdk";
3
+ import { DataReader } from "@vitest-agent/engine";
4
+ import { Tool } from "effect/unstable/ai";
5
+ import { CoverageTotals, FileCoverageReport } from "@vitest-agent/sdk";
4
6
 
5
7
  //#region src/tools/file-coverage.ts
6
- /**
7
- * `file_coverage` MCP tool — Schema-driven implementation.
8
- *
9
- * @packageDocumentation
10
- */
11
8
  const CoverageGlobalThresholds = Schema.Struct({
12
9
  statements: Schema.optional(Schema.Number),
13
10
  branches: Schema.optional(Schema.Number),
@@ -33,6 +30,11 @@ const FileCoverageAbsent = Schema.Struct({
33
30
  dataAvailable: Schema.Literal(false),
34
31
  filePath: Schema.String
35
32
  }).annotate({ identifier: "FileCoverageAbsent" });
33
+ /**
34
+ * The `file_coverage` tool's success payload.
35
+ *
36
+ * @public
37
+ */
36
38
  const FileCoverageResult = Schema.Union([
37
39
  FileCoverageMatched,
38
40
  FileCoverageNoMatch,
@@ -73,10 +75,21 @@ const FileCoverageAsMarkdown = FileCoverageResult.pipe(Schema.decodeTo(Schema.St
73
75
  decode: SchemaGetter.transform((data) => formatFileCoverageMarkdown(data)),
74
76
  encode: SchemaGetter.forbidden(() => "FileCoverageAsMarkdown is one-way.")
75
77
  }));
76
- const fileCoverage = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
77
- filePath: Schema.String,
78
- project: Schema.optional(Schema.String)
79
- }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
78
+ /**
79
+ * The `file_coverage` tool's parameters.
80
+ *
81
+ * @public
82
+ */
83
+ const FileCoverageInput = Schema.Struct({
84
+ filePath: Schema.String.annotate({ description: "Source file path to check coverage for" }),
85
+ project: Schema.optionalKey(Schema.String).annotate({ description: "Project name" })
86
+ });
87
+ /**
88
+ * Handler for {@link fileCoverageTool}.
89
+ *
90
+ * @public
91
+ */
92
+ const handleFileCoverage = (input) => Effect.gen(function* () {
80
93
  const reader = yield* DataReader;
81
94
  const project = input.project ?? "default";
82
95
  const coverageOpt = yield* reader.getCoverage(project);
@@ -103,7 +116,18 @@ const fileCoverage = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Stru
103
116
  totals: coverage.totals,
104
117
  relatedTestFiles
105
118
  };
106
- })));
119
+ }).pipe(Effect.orDie);
120
+ /**
121
+ * The Effect-native `file_coverage` tool.
122
+ *
123
+ * @public
124
+ */
125
+ const fileCoverageTool = Tool.make("file_coverage", {
126
+ description: "Use when you need coverage for one source file: per-metric values, uncovered lines, and related tests. Returns markdown in content[] and a typed JSON object in structuredContent ({ dataAvailable, matched?, filePath, report?, totals?, relatedTestFiles[] }).",
127
+ parameters: FileCoverageInput,
128
+ success: FileCoverageResult,
129
+ dependencies: [DataReader]
130
+ }).annotate(Tool.Title, "File coverage").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true).annotate(RenderText, (encoded) => formatFileCoverageMarkdown(encoded));
107
131
 
108
132
  //#endregion
109
- export { FileCoverageAsMarkdown, FileCoverageResult, fileCoverage, formatFileCoverageMarkdown };
133
+ export { FileCoverageInput, FileCoverageResult, fileCoverageTool, formatFileCoverageMarkdown, handleFileCoverage };
package/tools/help.js CHANGED
@@ -1,12 +1,24 @@
1
- import { publicProcedure } from "../context.js";
2
- import { Schema } from "effect";
1
+ import { RenderText } from "../annotations.js";
2
+ import { Effect, Schema } from "effect";
3
+ import { Tool } from "effect/unstable/ai";
3
4
 
4
5
  //#region src/tools/help.ts
6
+ /**
7
+ * The `help` tool's success payload.
8
+ *
9
+ * @public
10
+ */
5
11
  const HelpResult = Schema.Struct({ helpText: Schema.String.annotate({ description: "Markdown table of every MCP tool with parameters and a one-line description." }) }).annotate({
6
12
  identifier: "HelpResult",
7
13
  title: "help result",
8
14
  description: "Static help reference. Read structuredContent.helpText programmatically; the same string lives in content[].text for transcripts."
9
15
  });
16
+ /**
17
+ * The static markdown `help` returns. Exported so `__test__/help-drift.test.ts`
18
+ * can pin its tool and prompt rows to the served toolkit and prompt layer.
19
+ *
20
+ * @internal
21
+ */
10
22
  const HELP_TEXT = `# vitest-agent MCP Tools
11
23
 
12
24
  > Consolidated tool surface (Phase 3 of the agent-agnostic taxonomy).
@@ -135,6 +147,19 @@ const HELP_TEXT = `# vitest-agent MCP Tools
135
147
  - \`{ action: "list_by_goal", goalId }\`
136
148
  - \`{ action: "list_by_tdd_task", tddTaskId }\`
137
149
 
150
+ ## Prompts
151
+
152
+ Six framing-only prompts (\`prompts/get\`; Claude Code surfaces them as slash commands):
153
+
154
+ | Prompt | Arguments | Description |
155
+ | ------ | --------- | ----------- |
156
+ | \`triage\` | \`project?\` | Orient toward a triage workflow over the most recent run |
157
+ | \`why-flaky\` | \`test\`, \`project?\` | Diagnose why a named test is flaky |
158
+ | \`regression-since-pass\` | \`test\`, \`project?\` | Walk back from the last passing run to the change that broke it |
159
+ | \`explain-failure\` | \`signature\` | Root-cause explanation from a failure signature's recurrence history |
160
+ | \`tdd-resume\` | \`sessionId?\` | Resume the active TDD task from its current phase |
161
+ | \`wrapup\` | \`kind?\`, \`since?\` | The same wrap-up content the post-hooks emit automatically |
162
+
138
163
  ## Parameter Key
139
164
 
140
165
  - **Required** parameters are unmarked
@@ -143,7 +168,23 @@ const HELP_TEXT = `# vitest-agent MCP Tools
143
168
  - \`state\` accepts: \`passed\`, \`failed\`, \`skipped\`, \`pending\`
144
169
  - \`scope\` accepts: \`global\`, \`project\`, \`module\`, \`suite\`, \`test\`, \`note\`
145
170
  `;
146
- const help = publicProcedure.query(() => ({ helpText: HELP_TEXT }));
171
+ /**
172
+ * The Effect-native `help` tool. Renders the help markdown as the text
173
+ * channel via `RenderText` while `structuredContent.helpText` carries the
174
+ * same string for programmatic readers.
175
+ *
176
+ * @public
177
+ */
178
+ const helpTool = Tool.make("help", {
179
+ description: "List all available MCP tools with parameters. Read structuredContent.helpText programmatically; the same markdown lives in content[].text.",
180
+ success: HelpResult
181
+ }).annotate(Tool.Title, "Help").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true).annotate(RenderText, (encoded) => encoded.helpText);
182
+ /**
183
+ * Handler for {@link helpTool}.
184
+ *
185
+ * @public
186
+ */
187
+ const handleHelp = () => Effect.succeed({ helpText: HELP_TEXT });
147
188
 
148
189
  //#endregion
149
- export { HelpResult, help };
190
+ export { HELP_TEXT, HelpResult, handleHelp, helpTool };
package/tools/history.js CHANGED
@@ -1,17 +1,10 @@
1
- import { publicProcedure } from "../context.js";
1
+ import { RenderText } from "../annotations.js";
2
2
  import { Effect, Schema, SchemaGetter } from "effect";
3
- import { DataReader, HistoryRecord } from "@vitest-agent/sdk";
3
+ import { DataReader } from "@vitest-agent/engine";
4
+ import { Tool } from "effect/unstable/ai";
5
+ import { HistoryRecord } from "@vitest-agent/sdk";
4
6
 
5
7
  //#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
8
  const FlakyTestRow = Schema.Struct({
16
9
  fullName: Schema.String.annotate({ description: "Full hierarchical test name (`describe > it`)." }),
17
10
  modulePath: Schema.String.annotate({ description: "Project-relative test module path -- disambiguates same-named tests across files." }),
@@ -44,6 +37,11 @@ const RecoveredTestRow = Schema.Struct({
44
37
  identifier: "RecoveredTestRow",
45
38
  description: "A test whose latest run passed after the previous one failed."
46
39
  });
40
+ /**
41
+ * The `test_history` tool's success payload.
42
+ *
43
+ * @public
44
+ */
47
45
  const TestHistoryResult = Schema.Struct({
48
46
  project: Schema.String.annotate({ description: "Workspace project key the history was computed for." }),
49
47
  hasData: Schema.Boolean.annotate({ description: "`false` when no history rows exist for the project — agent should suggest running tests first." }),
@@ -91,12 +89,23 @@ const TestHistoryAsMarkdown = TestHistoryResult.pipe(Schema.decodeTo(Schema.Stri
91
89
  decode: SchemaGetter.transform((data) => formatTestHistoryMarkdown(data)),
92
90
  encode: SchemaGetter.forbidden(() => "TestHistoryAsMarkdown is one-way: markdown cannot be parsed back to TestHistoryResult.")
93
91
  }));
94
- const testHistory = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
95
- project: Schema.String,
96
- testName: Schema.optional(Schema.String).annotate({ description: "Exact full_name match — narrows to a single test's history." }),
97
- modulePath: Schema.optional(Schema.String).annotate({ description: "Exact module_path match — narrows to tests in one file." }),
98
- limit: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0)).annotate({ description: "Max runs kept per test, most-recent-first. Must be a positive integer. Default 20." }))
99
- }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
92
+ /**
93
+ * The `test_history` tool's parameters.
94
+ *
95
+ * @public
96
+ */
97
+ const TestHistoryInput = Schema.Struct({
98
+ project: Schema.String.annotate({ description: "Project name (required)" }),
99
+ testName: Schema.optionalKey(Schema.String).annotate({ description: "Exact full_name match — narrows to a single test's history." }),
100
+ modulePath: Schema.optionalKey(Schema.String).annotate({ description: "Exact module_path match — narrows to tests in one file." }),
101
+ limit: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0)).annotate({ description: "Max runs kept per test, most-recent-first. Must be a positive integer. Default 20." }))
102
+ });
103
+ /**
104
+ * Handler for {@link testHistoryTool}.
105
+ *
106
+ * @public
107
+ */
108
+ const handleTestHistory = (input) => Effect.gen(function* () {
100
109
  const reader = yield* DataReader;
101
110
  const scopeOptions = {
102
111
  ...input.testName !== void 0 && { testName: input.testName },
@@ -131,7 +140,18 @@ const testHistory = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struc
131
140
  persistent,
132
141
  recovered
133
142
  };
134
- })));
143
+ }).pipe(Effect.orDie);
144
+ /**
145
+ * The Effect-native `test_history` tool.
146
+ *
147
+ * @public
148
+ */
149
+ const testHistoryTool = Tool.make("test_history", {
150
+ description: "Use when failures recur and you need flaky, persistent, and recovered test classifications. Returns markdown in content[] and a typed JSON object in structuredContent (project, hasData, history, flaky[], persistent[], recovered[]). Optional testName/modulePath narrow to a single test; limit caps runs kept per test (default 20) — omit all three only when you actually need the whole project's history.",
151
+ parameters: TestHistoryInput,
152
+ success: TestHistoryResult,
153
+ dependencies: [DataReader]
154
+ }).annotate(Tool.Title, "Test history").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true).annotate(RenderText, (encoded) => formatTestHistoryMarkdown(encoded));
135
155
 
136
156
  //#endregion
137
- export { TestHistoryAsMarkdown, TestHistoryResult, formatTestHistoryMarkdown, testHistory };
157
+ export { TestHistoryInput, TestHistoryResult, formatTestHistoryMarkdown, handleTestHistory, testHistoryTool };
@@ -1,18 +1,12 @@
1
- import { idempotentProcedure } from "../middleware/idempotency.js";
1
+ import { RenderText } from "../annotations.js";
2
+ import { McpSession } from "../session.js";
3
+ import { IdempotentReplayMarker } from "../utils/replay-marker.js";
2
4
  import { Effect, Match, Option, Schema } from "effect";
3
- import { DataReader, DataStore, DataStoreError } from "@vitest-agent/sdk";
5
+ import { DataReader, DataStore } from "@vitest-agent/engine";
6
+ import { Tool } from "effect/unstable/ai";
7
+ import { DataStoreError } from "@vitest-agent/sdk";
4
8
 
5
9
  //#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
10
  const HypothesisRowSchema = Schema.Struct({
17
11
  id: Schema.Number,
18
12
  sessionId: Schema.Number,
@@ -30,7 +24,10 @@ const HypothesisRecordOk = Schema.Struct({
30
24
  action: Schema.Literal("record"),
31
25
  id: Schema.Finite.annotate({ description: "Newly inserted hypothesis row primary key." })
32
26
  });
33
- const HypothesisValidateOk = Schema.Struct({ action: Schema.Literal("validate") });
27
+ const HypothesisValidateOk = Schema.Struct({
28
+ action: Schema.Literal("validate"),
29
+ ...IdempotentReplayMarker
30
+ });
34
31
  const HypothesisListOk = Schema.Struct({
35
32
  action: Schema.Literal("list"),
36
33
  count: Schema.Number,
@@ -55,117 +52,136 @@ const formatHypothesisListMarkdown = (data) => {
55
52
  }
56
53
  return lines.join("\n");
57
54
  };
55
+ /** Number-or-numeric-string id: LLM orchestrators routinely stringify numeric tool inputs. */
56
+ const CoercibleId = Schema.Union([Schema.Finite, Schema.FiniteFromString]);
57
+ /** The text channel: `list` renders markdown; `record` / `validate` render the pretty-printed JSON. */
58
+ const renderHypothesisText = (data) => data.action === "list" ? formatHypothesisListMarkdown(data) : JSON.stringify(data, null, 2);
58
59
  const RecordVariant = Schema.Struct({
59
- action: Schema.Literal("record"),
60
- tddTaskId: Schema.optional(Schema.Union([Schema.Number, Schema.FiniteFromString])),
61
- sessionId: Schema.optional(Schema.Union([Schema.Number, Schema.FiniteFromString])),
62
- content: Schema.String,
63
- createdTurnId: Schema.optional(Schema.Number),
64
- citedTestErrorId: Schema.optional(Schema.Number),
65
- citedStackFrameId: Schema.optional(Schema.Number)
60
+ action: Schema.Literal("record").annotate({ description: "CRUD discriminator" }),
61
+ tddTaskId: Schema.optionalKey(CoercibleId).annotate({ description: "record: tdd task id returned by tdd_task action='start' — binds the hypothesis to that task's session deterministically" }),
62
+ sessionId: Schema.optionalKey(CoercibleId).annotate({ description: "list: filter by session id. record: dev/test fallback only — ignored when host context is recovered; never pass a tddTaskId value here" }),
63
+ content: Schema.String.annotate({ description: "Hypothesis content (action=record)" }),
64
+ createdTurnId: Schema.optionalKey(Schema.Finite),
65
+ citedTestErrorId: Schema.optionalKey(Schema.Finite),
66
+ citedStackFrameId: Schema.optionalKey(Schema.Finite)
66
67
  });
67
68
  const ValidateVariant = Schema.Struct({
68
- action: Schema.Literal("validate"),
69
- id: Schema.Number,
69
+ action: Schema.Literal("validate").annotate({ description: "CRUD discriminator" }),
70
+ id: Schema.Finite.annotate({ description: "Hypothesis id (action=validate)" }),
70
71
  outcome: Schema.Literals([
71
72
  "confirmed",
72
73
  "refuted",
73
74
  "abandoned"
74
- ]),
75
- validatedTurnId: Schema.optional(Schema.Number),
76
- validatedAt: Schema.optional(Schema.String)
75
+ ]).annotate({ description: "validate: 'confirmed'|'refuted'|'abandoned'; list filter may include 'open'" }),
76
+ validatedTurnId: Schema.optionalKey(Schema.Finite),
77
+ validatedAt: Schema.optionalKey(Schema.String).annotate({ description: "ISO 8601 timestamp (action=validate)" })
77
78
  });
78
79
  const ListVariant = Schema.Struct({
79
- action: Schema.Literal("list"),
80
- sessionId: Schema.optional(Schema.Number),
81
- outcome: Schema.optional(Schema.Literals([
80
+ action: Schema.Literal("list").annotate({ description: "CRUD discriminator" }),
81
+ sessionId: Schema.optionalKey(Schema.Finite).annotate({ description: "list: filter by session id. record: dev/test fallback only — ignored when host context is recovered; never pass a tddTaskId value here" }),
82
+ outcome: Schema.optionalKey(Schema.Literals([
82
83
  "confirmed",
83
84
  "refuted",
84
85
  "abandoned",
85
86
  "open"
86
- ])),
87
- limit: Schema.optional(Schema.Number)
87
+ ])).annotate({ description: "validate: 'confirmed'|'refuted'|'abandoned'; list filter may include 'open'" }),
88
+ limit: Schema.optionalKey(Schema.Finite)
88
89
  });
90
+ /**
91
+ * The `hypothesis` tool's parameters — a union discriminated on `action`.
92
+ *
93
+ * @public
94
+ */
89
95
  const HypothesisInput = Schema.Union([
90
96
  RecordVariant,
91
97
  ValidateVariant,
92
98
  ListVariant
93
99
  ]);
94
100
  /**
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).
101
+ * Handler for {@link hypothesisTool}. `record` fails (as a defect, so it
102
+ * reaches the agent as the `UnexpectedToolError` envelope) when the
103
+ * binding session cannot be resolved.
104
+ *
105
+ * @public
99
106
  */
100
- const HYPOTHESIS_ACTIONS = [
101
- "record",
102
- "validate",
103
- "list"
104
- ];
105
- const hypothesis = idempotentProcedure.input(Schema.toStandardSchemaV1(HypothesisInput)).mutation(async ({ ctx, input }) => {
106
- return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
107
- record: (variant) => Effect.gen(function* () {
108
- const store = yield* DataStore;
109
- const reader = yield* DataReader;
110
- let resolvedSessionId;
111
- if (variant.tddTaskId !== void 0) {
112
- const bound = yield* reader.getSessionByTddTaskId(variant.tddTaskId);
113
- if (Option.isNone(bound)) return yield* Effect.fail(new DataStoreError({
114
- operation: "write",
115
- table: "hypotheses",
116
- reason: `unknown tddTaskId ${variant.tddTaskId}: no session found to attribute hypothesis`
117
- }));
118
- resolvedSessionId = bound.value.id;
119
- } else {
120
- const sc = ctx.sessionContext.get();
121
- resolvedSessionId = variant.sessionId;
122
- if (sc !== null) {
123
- const main = yield* reader.getSessionByChatId(sc.chatId);
124
- if (Option.isSome(main)) {
125
- const sub = yield* reader.findActiveSubagentSession(main.value.id);
126
- resolvedSessionId = Option.isSome(sub) ? sub.value.id : main.value.id;
127
- }
107
+ const handleHypothesis = (input) => Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
108
+ record: (variant) => Effect.gen(function* () {
109
+ const store = yield* DataStore;
110
+ const reader = yield* DataReader;
111
+ const session = yield* McpSession;
112
+ let resolvedSessionId;
113
+ if (variant.tddTaskId !== void 0) {
114
+ const bound = yield* reader.getSessionByTddTaskId(variant.tddTaskId);
115
+ if (Option.isNone(bound)) return yield* Effect.fail(new DataStoreError({
116
+ operation: "write",
117
+ table: "hypotheses",
118
+ reason: `unknown tddTaskId ${variant.tddTaskId}: no session found to attribute hypothesis`
119
+ }));
120
+ resolvedSessionId = bound.value.id;
121
+ } else {
122
+ const sc = session.sessionContext.get();
123
+ resolvedSessionId = variant.sessionId;
124
+ if (sc !== null) {
125
+ const main = yield* reader.getSessionByChatId(sc.chatId);
126
+ if (Option.isSome(main)) {
127
+ const sub = yield* reader.findActiveSubagentSession(main.value.id);
128
+ resolvedSessionId = Option.isSome(sub) ? sub.value.id : main.value.id;
128
129
  }
129
- if (resolvedSessionId === void 0) return yield* Effect.fail(new DataStoreError({
130
- operation: "write",
131
- table: "hypotheses",
132
- reason: "no recovered session context: pass tddTaskId (the id returned by tdd_task action:start) to bind this hypothesis to your task's session — do not retry with a raw sessionId, and never pass tddTaskId under a sessionId key"
133
- }));
134
130
  }
135
- return {
136
- action: "record",
137
- id: yield* store.writeHypothesis({
138
- sessionId: resolvedSessionId,
139
- content: variant.content,
140
- ...variant.createdTurnId !== void 0 && { createdTurnId: variant.createdTurnId },
141
- ...variant.citedTestErrorId !== void 0 && { citedTestErrorId: variant.citedTestErrorId },
142
- ...variant.citedStackFrameId !== void 0 && { citedStackFrameId: variant.citedStackFrameId }
143
- })
144
- };
145
- }),
146
- validate: (variant) => Effect.gen(function* () {
147
- yield* (yield* DataStore).validateHypothesis({
148
- id: variant.id,
149
- outcome: variant.outcome,
150
- validatedAt: variant.validatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
151
- ...variant.validatedTurnId !== void 0 && { validatedTurnId: variant.validatedTurnId }
152
- });
153
- return { action: "validate" };
154
- }),
155
- list: (variant) => Effect.gen(function* () {
156
- const rows = yield* (yield* DataReader).listHypotheses({
157
- ...variant.sessionId !== void 0 && { sessionId: variant.sessionId },
158
- ...variant.outcome !== void 0 && { outcome: variant.outcome },
159
- ...variant.limit !== void 0 && { limit: variant.limit }
160
- });
161
- return {
162
- action: "list",
163
- count: rows.length,
164
- hypotheses: rows
165
- };
166
- })
167
- })));
168
- });
131
+ if (resolvedSessionId === void 0) return yield* Effect.fail(new DataStoreError({
132
+ operation: "write",
133
+ table: "hypotheses",
134
+ reason: "no recovered session context: pass tddTaskId (the id returned by tdd_task action:start) to bind this hypothesis to your task's session — do not retry with a raw sessionId, and never pass tddTaskId under a sessionId key"
135
+ }));
136
+ }
137
+ return {
138
+ action: "record",
139
+ id: yield* store.writeHypothesis({
140
+ sessionId: resolvedSessionId,
141
+ content: variant.content,
142
+ ...variant.createdTurnId !== void 0 && { createdTurnId: variant.createdTurnId },
143
+ ...variant.citedTestErrorId !== void 0 && { citedTestErrorId: variant.citedTestErrorId },
144
+ ...variant.citedStackFrameId !== void 0 && { citedStackFrameId: variant.citedStackFrameId }
145
+ })
146
+ };
147
+ }),
148
+ validate: (variant) => Effect.gen(function* () {
149
+ yield* (yield* DataStore).validateHypothesis({
150
+ id: variant.id,
151
+ outcome: variant.outcome,
152
+ validatedAt: variant.validatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
153
+ ...variant.validatedTurnId !== void 0 && { validatedTurnId: variant.validatedTurnId }
154
+ });
155
+ return { action: "validate" };
156
+ }),
157
+ list: (variant) => Effect.gen(function* () {
158
+ const rows = yield* (yield* DataReader).listHypotheses({
159
+ ...variant.sessionId !== void 0 && { sessionId: variant.sessionId },
160
+ ...variant.outcome !== void 0 && { outcome: variant.outcome },
161
+ ...variant.limit !== void 0 && { limit: variant.limit }
162
+ });
163
+ return {
164
+ action: "list",
165
+ count: rows.length,
166
+ hypotheses: rows
167
+ };
168
+ })
169
+ })).pipe(Effect.orDie);
170
+ /**
171
+ * The Effect-native `hypothesis` tool.
172
+ *
173
+ * @public
174
+ */
175
+ const hypothesisTool = Tool.make("hypothesis", {
176
+ 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.",
177
+ parameters: HypothesisInput,
178
+ success: HypothesisResult,
179
+ dependencies: [
180
+ DataReader,
181
+ DataStore,
182
+ McpSession
183
+ ]
184
+ }).annotate(Tool.Title, "Hypothesis").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false).annotate(RenderText, (encoded) => renderHypothesisText(encoded));
169
185
 
170
186
  //#endregion
171
- export { HYPOTHESIS_ACTIONS, HypothesisResult, formatHypothesisListMarkdown, hypothesis };
187
+ export { HypothesisInput, HypothesisResult, formatHypothesisListMarkdown, handleHypothesis, hypothesisTool };