@vitest-agent/mcp 4.3.1 → 5.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.
@@ -1,9 +1,9 @@
1
1
  import { McpSession } from "../session.js";
2
2
  import { IdempotentReplayMarker } from "../utils/replay-marker.js";
3
+ import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
3
4
  import { Effect, Match, Option, Schema } from "effect";
4
5
  import { DataReader, DataStore } from "@vitest-agent/engine";
5
6
  import { Tool } from "effect/unstable/ai";
6
- import { DataStoreError } from "@vitest-agent/sdk";
7
7
 
8
8
  //#region src/tools/hypothesis.ts
9
9
  const HypothesisRowSchema = Schema.Struct({
@@ -32,11 +32,11 @@ const HypothesisListOk = Schema.Struct({
32
32
  count: Schema.Number,
33
33
  hypotheses: Schema.Array(HypothesisRowSchema)
34
34
  });
35
- const HypothesisResult = Schema.Union([
35
+ const HypothesisResult = ToolOutputSchema.objectRooted(Schema.Union([
36
36
  HypothesisRecordOk,
37
37
  HypothesisValidateOk,
38
38
  HypothesisListOk
39
- ]).annotate({
39
+ ])).annotate({
40
40
  identifier: "HypothesisResult",
41
41
  title: "hypothesis result",
42
42
  description: "Discriminate on `action`. record returns the new id; list returns the matching rows; validate returns an empty acknowledgement."
@@ -85,9 +85,11 @@ const HypothesisInput = Schema.Union([
85
85
  ListVariant
86
86
  ]);
87
87
  /**
88
- * Handler for {@link hypothesisTool}. `record` fails (as a defect, so it
89
- * reaches the agent as the `UnexpectedToolError` envelope) when the
90
- * binding session cannot be resolved.
88
+ * Handler for {@link hypothesisTool}. `record` fails with a
89
+ * {@link ToolRefusal} (an `isError` result naming the fix) when the binding
90
+ * session cannot be resolved: an unknown `tddTaskId`, an unknown fallback
91
+ * `sessionId`, or no session at all; `validate` does for an unknown id. Any
92
+ * other store failure is a defect.
91
93
  *
92
94
  * @public
93
95
  */
@@ -99,10 +101,13 @@ const handleHypothesis = (input) => Match.value(input).pipe(Match.discriminators
99
101
  let resolvedSessionId;
100
102
  if (variant.tddTaskId !== void 0) {
101
103
  const bound = yield* reader.getSessionByTddTaskId(variant.tddTaskId);
102
- if (Option.isNone(bound)) return yield* Effect.fail(new DataStoreError({
103
- operation: "write",
104
- table: "hypotheses",
105
- reason: `unknown tddTaskId ${variant.tddTaskId}: no session found to attribute hypothesis`
104
+ if (Option.isNone(bound)) return yield* Effect.fail(ToolRefusal.refuse(`Unknown tddTaskId ${variant.tddTaskId}: no session to attribute the hypothesis to.`, {
105
+ hint: "Pass the tddTaskId returned by tdd_task action='start' (tdd_task action='get' confirms it exists).",
106
+ suggestedTool: "tdd_task",
107
+ suggestedArgs: {
108
+ action: "get",
109
+ tddTaskId: variant.tddTaskId
110
+ }
106
111
  }));
107
112
  resolvedSessionId = bound.value.id;
108
113
  } else {
@@ -115,10 +120,15 @@ const handleHypothesis = (input) => Match.value(input).pipe(Match.discriminators
115
120
  resolvedSessionId = Option.isSome(sub) ? sub.value.id : main.value.id;
116
121
  }
117
122
  }
118
- if (resolvedSessionId === void 0) return yield* Effect.fail(new DataStoreError({
119
- operation: "write",
120
- table: "hypotheses",
121
- 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"
123
+ if (resolvedSessionId === void 0) return yield* Effect.fail(ToolRefusal.refuse("No session to attribute the hypothesis to: no host session context was recovered.", {
124
+ hint: "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.",
125
+ suggestedTool: "tdd_task",
126
+ suggestedArgs: { action: "start" }
127
+ }));
128
+ if (resolvedSessionId === variant.sessionId && Option.isNone(yield* reader.getSessionById(resolvedSessionId))) return yield* Effect.fail(ToolRefusal.refuse(`Unknown sessionId ${resolvedSessionId}.`, {
129
+ hint: "Pass tddTaskId (the id returned by tdd_task action='start') instead of a raw sessionId.",
130
+ suggestedTool: "tdd_task",
131
+ suggestedArgs: { action: "start" }
122
132
  }));
123
133
  }
124
134
  return {
@@ -138,7 +148,11 @@ const handleHypothesis = (input) => Match.value(input).pipe(Match.discriminators
138
148
  outcome: variant.outcome,
139
149
  validatedAt: variant.validatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
140
150
  ...variant.validatedTurnId !== void 0 && { validatedTurnId: variant.validatedTurnId }
141
- });
151
+ }).pipe(Effect.catchIf((error) => error.reason.startsWith("unknown hypothesis id"), () => Effect.fail(ToolRefusal.refuse(`Unknown hypothesis id ${variant.id}.`, {
152
+ hint: "Use the id hypothesis action='record' returned, or find it with hypothesis action='list'.",
153
+ suggestedTool: "hypothesis",
154
+ suggestedArgs: { action: "list" }
155
+ }))));
142
156
  return { action: "validate" };
143
157
  }),
144
158
  list: (variant) => Effect.gen(function* () {
@@ -153,22 +167,18 @@ const handleHypothesis = (input) => Match.value(input).pipe(Match.discriminators
153
167
  hypotheses: rows
154
168
  };
155
169
  })
156
- })).pipe(Effect.orDie);
170
+ })).pipe(Effect.catchTag("DataStoreError", Effect.die));
157
171
  /**
158
172
  * The Effect-native `hypothesis` tool.
159
173
  *
160
174
  * @public
161
175
  */
162
- const hypothesisTool = Tool.make("hypothesis", {
176
+ const hypothesisTool = McpToolkit.unionTool("hypothesis", {
163
177
  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.",
164
178
  parameters: HypothesisInput,
165
179
  success: HypothesisResult,
166
- dependencies: [
167
- DataReader,
168
- DataStore,
169
- McpSession
170
- ]
171
- }).annotate(Tool.Title, "Hypothesis").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false);
180
+ failure: ToolRefusal
181
+ }).addDependency(DataReader).addDependency(DataStore).addDependency(McpSession).annotate(Tool.Title, "Hypothesis").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false);
172
182
 
173
183
  //#endregion
174
184
  export { HypothesisInput, HypothesisResult, handleHypothesis, hypothesisTool };
@@ -1,4 +1,5 @@
1
1
  import { collectProjectRows, resolveProjectTargets } from "./_project-groups.js";
2
+ import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
2
3
  import { Effect, Match, Option, Schema } from "effect";
3
4
  import { DataReader } from "@vitest-agent/engine";
4
5
  import { Tool } from "effect/unstable/ai";
@@ -114,7 +115,7 @@ const TagInventoryUnscoped = Schema.Struct({
114
115
  *
115
116
  * @public
116
117
  */
117
- const InventoryResult = Schema.Union([
118
+ const InventoryResult = ToolOutputSchema.objectRooted(Schema.Union([
118
119
  ProjectInventory,
119
120
  ModuleInventory,
120
121
  SuiteInventory,
@@ -123,7 +124,7 @@ const InventoryResult = Schema.Union([
123
124
  SessionListInventory,
124
125
  TagInventoryScoped,
125
126
  TagInventoryUnscoped
126
- ]).annotate({
127
+ ])).annotate({
127
128
  identifier: "InventoryResult",
128
129
  title: "inventory result",
129
130
  description: "Discriminate on `inventoryKind`. project/module/suite carry counted lists; session_detail discriminates further on `found`; session_list returns the matching sessions; tag_scoped and tag_unscoped carry per-tag counts (the unscoped form also carries a `byProject` breakdown per tag)."
@@ -291,12 +292,12 @@ const handleInventory = (input) => Match.value(input).pipe(Match.discriminatorsE
291
292
  *
292
293
  * @public
293
294
  */
294
- const inventoryTool = Tool.make("inventory", {
295
+ const inventoryTool = McpToolkit.unionTool("inventory", {
295
296
  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.",
296
297
  parameters: InventoryInput,
297
298
  success: InventoryResult,
298
- dependencies: [DataReader]
299
- }).annotate(Tool.Title, "Inventory").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true);
299
+ failure: ToolRefusal
300
+ }).addDependency(DataReader).annotate(Tool.Title, "Inventory").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true);
300
301
 
301
302
  //#endregion
302
303
  export { InventoryInput, InventoryResult, handleInventory, inventoryTool };
package/tools/note.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
1
2
  import { Effect, Match, Option, Schema } from "effect";
2
3
  import { DataReader, DataStore } from "@vitest-agent/engine";
3
4
  import { Tool } from "effect/unstable/ai";
@@ -64,7 +65,7 @@ const NoteSearchOk = Schema.Struct({
64
65
  *
65
66
  * @public
66
67
  */
67
- const NoteResult = Schema.Union([
68
+ const NoteResult = ToolOutputSchema.objectRooted(Schema.Union([
68
69
  NoteCreateOk,
69
70
  NoteListOk,
70
71
  NoteGetFound,
@@ -72,7 +73,7 @@ const NoteResult = Schema.Union([
72
73
  NoteUpdateOk,
73
74
  NoteDeleteOk,
74
75
  NoteSearchOk
75
- ]).annotate({
76
+ ])).annotate({
76
77
  identifier: "NoteResult",
77
78
  title: "note result",
78
79
  description: "Discriminate on `action`. `get` further discriminates on `found`."
@@ -210,12 +211,12 @@ const handleNote = (input) => Match.value(input).pipe(Match.discriminatorsExhaus
210
211
  *
211
212
  * @public
212
213
  */
213
- const noteTool = Tool.make("note", {
214
+ const noteTool = McpToolkit.unionTool("note", {
214
215
  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`).",
215
216
  parameters: NoteParams,
216
217
  success: NoteResult,
217
- dependencies: [DataReader, DataStore]
218
- }).annotate(Tool.Title, "Note").annotate(Tool.Readonly, false).annotate(Tool.Destructive, true).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false);
218
+ failure: ToolRefusal
219
+ }).addDependency(DataReader).addDependency(DataStore).annotate(Tool.Title, "Note").annotate(Tool.Readonly, false).annotate(Tool.Destructive, true).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false);
219
220
 
220
221
  //#endregion
221
222
  export { NoteParams, NoteResult, handleNote, noteTool };
package/tools/overview.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ToolOutputSchema } from "@effected/mcp";
1
2
  import { Effect, Option, Schema } from "effect";
2
3
  import { DataReader } from "@vitest-agent/engine";
3
4
  import { Tool } from "effect/unstable/ai";
@@ -34,7 +35,7 @@ const OverviewAbsent = Schema.Struct({
34
35
  *
35
36
  * @public
36
37
  */
37
- const TestOverviewResult = Schema.Union([OverviewAvailable, OverviewAbsent]).annotate({
38
+ const TestOverviewResult = ToolOutputSchema.objectRooted(Schema.Union([OverviewAvailable, OverviewAbsent])).annotate({
38
39
  identifier: "TestOverviewResult",
39
40
  title: "test_overview result",
40
41
  description: "Per-project run metrics. Discriminate on `dataAvailable` for cold-start handling."
package/tools/ping.js CHANGED
@@ -1,5 +1,6 @@
1
- import { Effect, Schema } from "effect";
1
+ import { Effect, Option, Schema } from "effect";
2
2
  import { Tool } from "effect/unstable/ai";
3
+ import { CurrentDistribution, DistributionField } from "@effected/engine";
3
4
 
4
5
  //#region src/tools/ping.ts
5
6
  /**
@@ -7,10 +8,13 @@ import { Tool } from "effect/unstable/ai";
7
8
  *
8
9
  * @public
9
10
  */
10
- const PingResult = Schema.Struct({ message: Schema.Literal("pong").annotate({ description: "Constant `pong`. Presence confirms the MCP server responded." }) }).annotate({
11
+ const PingResult = Schema.Struct({
12
+ message: Schema.Literal("pong").annotate({ description: "Constant `pong`. Presence confirms the MCP server responded." }),
13
+ distribution: DistributionField.annotate({ description: "The package that shipped this bin (`{ name, version }`, e.g. `@vitest-agent/plugin`), or null when `@vitest-agent/mcp` was launched directly. Packaging provenance, not a version to compare." })
14
+ }).annotate({
11
15
  identifier: "PingResult",
12
16
  title: "ping result",
13
- description: "Liveness probe. Carries no data beyond the constant `pong` discriminant."
17
+ description: "Liveness probe: the constant `pong`, plus the distribution the server was launched through."
14
18
  });
15
19
  /**
16
20
  * The Effect-native `ping` tool. No parameters (the default
@@ -20,7 +24,7 @@ const PingResult = Schema.Struct({ message: Schema.Literal("pong").annotate({ de
20
24
  * @public
21
25
  */
22
26
  const pingTool = Tool.make("ping", {
23
- description: "Ping the MCP server — returns 'pong'. Used to verify hot-patch reload.",
27
+ description: "Ping the MCP server — returns 'pong' and the distribution (carrier package) it was launched through. Used to verify hot-patch reload.",
24
28
  success: PingResult
25
29
  }).annotate(Tool.Title, "Ping").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true);
26
30
  /**
@@ -28,7 +32,13 @@ const pingTool = Tool.make("ping", {
28
32
  *
29
33
  * @public
30
34
  */
31
- const handlePing = () => Effect.succeed({ message: "pong" });
35
+ const handlePing = () => Effect.gen(function* () {
36
+ const distribution = yield* CurrentDistribution;
37
+ return {
38
+ message: "pong",
39
+ distribution: Option.getOrNull(distribution)
40
+ };
41
+ });
32
42
 
33
43
  //#endregion
34
44
  export { PingResult, handlePing, pingTool };
@@ -1,3 +1,4 @@
1
+ import { ToolOutputSchema } from "@effected/mcp";
1
2
  import { Effect, Option, Schema } from "effect";
2
3
  import { DataReader, DataStore, deriveIdempotencyKey } from "@vitest-agent/engine";
3
4
  import { Tool } from "effect/unstable/ai";
@@ -75,7 +76,7 @@ const RegisterAgentFailure = Schema.Struct({
75
76
  *
76
77
  * @public
77
78
  */
78
- const RegisterAgentResult = Schema.Union([RegisterAgentSuccess, RegisterAgentFailure]).annotate({
79
+ const RegisterAgentResult = ToolOutputSchema.objectRooted(Schema.Union([RegisterAgentSuccess, RegisterAgentFailure])).annotate({
79
80
  identifier: "RegisterAgentResult",
80
81
  title: "register_agent result",
81
82
  description: "Discriminate on `ok`. The four failure codes are documented per their `code` literal."
@@ -1,5 +1,6 @@
1
1
  import { McpSession } from "../session.js";
2
2
  import { createRequire } from "node:module";
3
+ import { ToolOutputSchema } from "@effected/mcp";
3
4
  import { Data, Effect, Schema, Semaphore } from "effect";
4
5
  import { DataReader, DataStore } from "@vitest-agent/engine";
5
6
  import { Tool } from "effect/unstable/ai";
@@ -64,12 +65,12 @@ const RunTestsNoMatch = Schema.Struct({
64
65
  *
65
66
  * @public
66
67
  */
67
- const RunTestsResult = Schema.Union([
68
+ const RunTestsResult = ToolOutputSchema.objectRooted(Schema.Union([
68
69
  RunTestsOk,
69
70
  RunTestsTimeout,
70
71
  RunTestsError,
71
72
  RunTestsNoMatch
72
- ]).annotate({
73
+ ])).annotate({
73
74
  identifier: "RunTestsResult",
74
75
  title: "run_tests result",
75
76
  description: "Discriminate on `kind`. ok carries the full AgentReport plus per-test classifications; timeout / error are the two failure modes; no-match indicates that the resolved filter set matched zero test cases."
@@ -420,13 +421,22 @@ const makeBestEffortFork = (services) => (effect) => Effect.runFork(Effect.provi
420
421
  * `{ kind: "error" }` envelope, so it never rejects.
421
422
  */
422
423
  const runTestsBody = async (input, ctx) => {
423
- const files = input.files ? sanitizeTestArgs(input.files) : [];
424
- const project = input.project ? sanitizeTestArgs([input.project])[0] : void 0;
424
+ let files;
425
+ let project;
425
426
  const tagsInput = input.tags;
426
- if (tagsInput) {
427
- if (tagsInput.all) sanitizeTestArgs(tagsInput.all);
428
- if (tagsInput.any) sanitizeTestArgs(tagsInput.any);
429
- if (tagsInput.none) sanitizeTestArgs(tagsInput.none);
427
+ try {
428
+ files = input.files ? sanitizeTestArgs(input.files) : [];
429
+ project = input.project ? sanitizeTestArgs([input.project])[0] : void 0;
430
+ if (tagsInput) {
431
+ if (tagsInput.all) sanitizeTestArgs(tagsInput.all);
432
+ if (tagsInput.any) sanitizeTestArgs(tagsInput.any);
433
+ if (tagsInput.none) sanitizeTestArgs(tagsInput.none);
434
+ }
435
+ } catch (err) {
436
+ return {
437
+ kind: "error",
438
+ message: err instanceof Error ? err.message : String(err)
439
+ };
430
440
  }
431
441
  const resolvedExpression = composeTagExpression(tagsInput ?? null);
432
442
  const hasFilter = files.length > 0 || project !== void 0 || resolvedExpression !== null;
package/tools/status.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ToolOutputSchema } from "@effected/mcp";
1
2
  import { Effect, Option, Schema } from "effect";
2
3
  import { DataReader } from "@vitest-agent/engine";
3
4
  import { Tool } from "effect/unstable/ai";
@@ -20,7 +21,7 @@ const StatusAbsent = Schema.Struct({
20
21
  *
21
22
  * @public
22
23
  */
23
- const TestStatusResult = Schema.Union([StatusAvailable, StatusAbsent]).annotate({
24
+ const TestStatusResult = ToolOutputSchema.objectRooted(Schema.Union([StatusAvailable, StatusAbsent])).annotate({
24
25
  identifier: "TestStatusResult",
25
26
  title: "test_status result",
26
27
  description: "Per-project last-run summary. Discriminate on `dataAvailable` for cold-start handling."
@@ -1,9 +1,11 @@
1
1
  import { IdempotentReplayMarker } from "../utils/replay-marker.js";
2
2
  import { catchTddErrorsAsEnvelope } from "./_tdd-error-envelope.js";
3
+ import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
3
4
  import { Effect, Match, Option, Schema } from "effect";
4
5
  import { DataReader, DataStore } from "@vitest-agent/engine";
5
6
  import { Tool } from "effect/unstable/ai";
6
7
  import { BehaviorDetail, BehaviorRow } from "@vitest-agent/sdk";
8
+ import { Remediation } from "@effected/engine";
7
9
 
8
10
  //#region src/tools/tdd-behavior.ts
9
11
  /**
@@ -25,11 +27,7 @@ const TddBehaviorErrorEnvelope = Schema.Struct({
25
27
  ok: Schema.Literal(false).annotate({ description: "Discriminant — `false` when a tagged TDD error was caught." }),
26
28
  error: Schema.StructWithRest(Schema.Struct({
27
29
  _tag: Schema.String.annotate({ description: "Tagged error name (e.g. BehaviorNotFoundError, GoalNotFoundError)." }),
28
- remediation: Schema.Struct({
29
- suggestedTool: Schema.String,
30
- suggestedArgs: Schema.Record(Schema.String, Schema.Unknown),
31
- humanHint: Schema.String
32
- }).annotate({ description: "Suggested next action: the tool to call, its arguments, and a plain-language hint." })
30
+ remediation: Remediation.annotate({ description: "Suggested next action: a plain-language hint, the tool to call, and its arguments." })
33
31
  }), [Schema.Record(Schema.String, Schema.Unknown)])
34
32
  }).annotate({ identifier: "TddBehaviorErrorEnvelope" });
35
33
  const TddBehaviorCreateOk = Schema.Struct({
@@ -70,7 +68,7 @@ const TddBehaviorListByTddTaskOk = Schema.Struct({
70
68
  tddTaskId: Schema.Number,
71
69
  behaviors: Schema.Array(BehaviorRow)
72
70
  });
73
- const TddBehaviorResult = Schema.Union([
71
+ const TddBehaviorResult = ToolOutputSchema.objectRooted(Schema.Union([
74
72
  TddBehaviorCreateOk,
75
73
  TddBehaviorUpdateOk,
76
74
  TddBehaviorDeleteOk,
@@ -79,7 +77,7 @@ const TddBehaviorResult = Schema.Union([
79
77
  TddBehaviorListByGoalOk,
80
78
  TddBehaviorListByTddTaskOk,
81
79
  TddBehaviorErrorEnvelope
82
- ]).annotate({
80
+ ])).annotate({
83
81
  identifier: "TddBehaviorResult",
84
82
  title: "tdd_behavior result",
85
83
  description: "Discriminate on `action` (or `ok=false` for the tagged-error envelope)."
@@ -210,12 +208,12 @@ const handleTddBehavior = (input) => Match.value(input).pipe(Match.discriminator
210
208
  *
211
209
  * @public
212
210
  */
213
- const tddBehaviorTool = Tool.make("tdd_behavior", {
211
+ const tddBehaviorTool = McpToolkit.unionTool("tdd_behavior", {
214
212
  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.",
215
213
  parameters: TddBehaviorInput,
216
214
  success: TddBehaviorResult,
217
- dependencies: [DataReader, DataStore]
218
- }).annotate(Tool.Title, "TDD behavior").annotate(Tool.Readonly, false).annotate(Tool.Destructive, true).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false);
215
+ failure: ToolRefusal
216
+ }).addDependency(DataReader).addDependency(DataStore).annotate(Tool.Title, "TDD behavior").annotate(Tool.Readonly, false).annotate(Tool.Destructive, true).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false);
219
217
 
220
218
  //#endregion
221
219
  export { TddBehaviorInput, TddBehaviorResult, handleTddBehavior, tddBehaviorTool };
package/tools/tdd-goal.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { IdempotentReplayMarker } from "../utils/replay-marker.js";
2
2
  import { catchTddErrorsAsEnvelope } from "./_tdd-error-envelope.js";
3
+ import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
3
4
  import { Effect, Match, Option, Schema } from "effect";
4
5
  import { DataReader, DataStore } from "@vitest-agent/engine";
5
6
  import { Tool } from "effect/unstable/ai";
6
7
  import { GoalDetail, GoalRow } from "@vitest-agent/sdk";
8
+ import { Remediation } from "@effected/engine";
7
9
 
8
10
  //#region src/tools/tdd-goal.ts
9
11
  /**
@@ -25,11 +27,7 @@ const TddErrorEnvelope = Schema.Struct({
25
27
  ok: Schema.Literal(false).annotate({ description: "Discriminant — `false` when a tagged TDD error was caught." }),
26
28
  error: Schema.StructWithRest(Schema.Struct({
27
29
  _tag: Schema.String.annotate({ description: "Tagged error name (e.g. GoalNotFoundError, TddTaskNotFoundError)." }),
28
- remediation: Schema.Struct({
29
- suggestedTool: Schema.String,
30
- suggestedArgs: Schema.Record(Schema.String, Schema.Unknown),
31
- humanHint: Schema.String
32
- }).annotate({ description: "Suggested next action: the tool to call, its arguments, and a plain-language hint." })
30
+ remediation: Remediation.annotate({ description: "Suggested next action: a plain-language hint, the tool to call, and its arguments." })
33
31
  }), [Schema.Record(Schema.String, Schema.Unknown)])
34
32
  }).annotate({ identifier: "TddErrorEnvelope" });
35
33
  const TddGoalCreateOk = Schema.Struct({
@@ -64,7 +62,7 @@ const TddGoalListOk = Schema.Struct({
64
62
  tddTaskId: Schema.Number,
65
63
  goals: Schema.Array(GoalDetail).annotate({ description: "All goals for the TDD task, with their behaviors." })
66
64
  }).annotate({ identifier: "TddGoalListOk" });
67
- const TddGoalResult = Schema.Union([
65
+ const TddGoalResult = ToolOutputSchema.objectRooted(Schema.Union([
68
66
  TddGoalCreateOk,
69
67
  TddGoalUpdateOk,
70
68
  TddGoalDeleteOk,
@@ -72,7 +70,7 @@ const TddGoalResult = Schema.Union([
72
70
  TddGoalGetMissing,
73
71
  TddGoalListOk,
74
72
  TddErrorEnvelope
75
- ]).annotate({
73
+ ])).annotate({
76
74
  identifier: "TddGoalResult",
77
75
  title: "tdd_goal result",
78
76
  description: "Discriminate first on `action` (or on `ok=false` for the tagged-error envelope). Get returns `found:true|false`; create/update/delete/list return `ok:true` on success."
@@ -178,12 +176,12 @@ const handleTddGoal = (input) => Match.value(input).pipe(Match.discriminatorsExh
178
176
  *
179
177
  * @public
180
178
  */
181
- const tddGoalTool = Tool.make("tdd_goal", {
179
+ const tddGoalTool = McpToolkit.unionTool("tdd_goal", {
182
180
  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.",
183
181
  parameters: TddGoalInput,
184
182
  success: TddGoalResult,
185
- dependencies: [DataReader, DataStore]
186
- }).annotate(Tool.Title, "TDD goal").annotate(Tool.Readonly, false).annotate(Tool.Destructive, true).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false);
183
+ failure: ToolRefusal
184
+ }).addDependency(DataReader).addDependency(DataStore).annotate(Tool.Title, "TDD goal").annotate(Tool.Readonly, false).annotate(Tool.Destructive, true).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false);
187
185
 
188
186
  //#endregion
189
187
  export { TddGoalInput, TddGoalResult, handleTddGoal, tddGoalTool };
@@ -1,7 +1,9 @@
1
+ import { ToolOutputSchema } from "@effected/mcp";
1
2
  import { Effect, Option, Schema } from "effect";
2
3
  import { DataReader, DataStore } from "@vitest-agent/engine";
3
4
  import { Tool } from "effect/unstable/ai";
4
5
  import { requiredArtifactForTransition, transitionEnforcesBehaviorMatch, validatePhaseTransition } from "@vitest-agent/sdk";
6
+ import { Remediation } from "@effected/engine";
5
7
 
6
8
  //#region src/tools/tdd-phase-transition-request.ts
7
9
  /**
@@ -45,11 +47,7 @@ const denialReasonLiteral = Schema.Literals([
45
47
  "evidence_not_for_behavior",
46
48
  "evidence_test_was_already_failing"
47
49
  ]);
48
- const RemediationSchema = Schema.Struct({
49
- suggestedTool: Schema.String.annotate({ description: "Next tool the agent should call to make progress." }),
50
- suggestedArgs: Schema.Record(Schema.String, Schema.Unknown).annotate({ description: "Concrete arguments for `suggestedTool` that fix the underlying issue." }),
51
- humanHint: Schema.String.annotate({ description: "Plain-language explanation of what to do next." })
52
- }).annotate({ identifier: "PhaseTransitionRemediation" });
50
+ const RemediationSchema = Remediation.annotate({ description: "Suggested next action: `hint` explains what to do, `suggestedTool` is the tool to call next, and `suggestedArgs` its arguments." });
53
51
  const PhaseTransitionAccepted = Schema.Struct({
54
52
  accepted: Schema.Literal(true).annotate({ description: "Discriminant — `true` when the transition was granted." }),
55
53
  phase: phaseLiteral.annotate({ description: "Phase the session is now in (echo of `requestedPhase`)." }),
@@ -74,7 +72,7 @@ const PhaseTransitionDenied = Schema.Struct({
74
72
  *
75
73
  * @public
76
74
  */
77
- const PhaseTransitionResult = Schema.Union([PhaseTransitionAccepted, PhaseTransitionDenied]).annotate({
75
+ const PhaseTransitionResult = ToolOutputSchema.objectRooted(Schema.Union([PhaseTransitionAccepted, PhaseTransitionDenied])).annotate({
78
76
  identifier: "PhaseTransitionResult",
79
77
  title: "tdd_phase_transition_request result",
80
78
  description: "Discriminate on `accepted`. Acceptance carries the new phaseId; denial carries a typed reason and a remediation pointer."
@@ -124,7 +122,7 @@ const handlePhaseTransitionRequest = (input) => Effect.gen(function* () {
124
122
  action: "list",
125
123
  tddTaskId: input.tddTaskId
126
124
  },
127
- humanHint: `No tdd_session_goals row with id=${input.goalId}. Call tdd_goal({ action: "list" }) to find the correct goal id.`
125
+ hint: `No tdd_session_goals row with id=${input.goalId}. Call tdd_goal({ action: "list" }) to find the correct goal id.`
128
126
  }
129
127
  };
130
128
  if (goalOpt.value.sessionId !== input.tddTaskId) return {
@@ -137,7 +135,7 @@ const handlePhaseTransitionRequest = (input) => Effect.gen(function* () {
137
135
  action: "list",
138
136
  tddTaskId: input.tddTaskId
139
137
  },
140
- humanHint: `Goal id=${input.goalId} belongs to TDD task ${goalOpt.value.sessionId}, not the requested tddTaskId=${input.tddTaskId}. Pass the tddTaskId of the goal's parent task, or pick a goal that belongs to the active task.`
138
+ hint: `Goal id=${input.goalId} belongs to TDD task ${goalOpt.value.sessionId}, not the requested tddTaskId=${input.tddTaskId}. Pass the tddTaskId of the goal's parent task, or pick a goal that belongs to the active task.`
141
139
  }
142
140
  };
143
141
  if (goalOpt.value.status !== "in_progress") return {
@@ -150,7 +148,7 @@ const handlePhaseTransitionRequest = (input) => Effect.gen(function* () {
150
148
  id: input.goalId,
151
149
  status: "in_progress"
152
150
  },
153
- humanHint: `Goal id=${input.goalId} has status '${goalOpt.value.status}'. Phase transitions require the goal to be in_progress. Call tdd_goal_update({status:'in_progress'}) before requesting transitions.`
151
+ hint: `Goal id=${input.goalId} has status '${goalOpt.value.status}'. Phase transitions require the goal to be in_progress. Call tdd_goal_update({status:'in_progress'}) before requesting transitions.`
154
152
  }
155
153
  };
156
154
  if (input.behaviorId !== void 0) {
@@ -165,7 +163,7 @@ const handlePhaseTransitionRequest = (input) => Effect.gen(function* () {
165
163
  scope: "goal",
166
164
  goalId: input.goalId
167
165
  },
168
- humanHint: `No tdd_session_behaviors row with id=${input.behaviorId}. Call tdd_behavior_list to find the correct behavior id.`
166
+ hint: `No tdd_session_behaviors row with id=${input.behaviorId}. Call tdd_behavior_list to find the correct behavior id.`
169
167
  }
170
168
  };
171
169
  if (behaviorOpt.value.goalId !== input.goalId) return {
@@ -175,7 +173,7 @@ const handlePhaseTransitionRequest = (input) => Effect.gen(function* () {
175
173
  remediation: {
176
174
  suggestedTool: "tdd_behavior_get",
177
175
  suggestedArgs: { id: input.behaviorId },
178
- humanHint: `Behavior id=${input.behaviorId} belongs to goal ${behaviorOpt.value.goalId}, not the requested goalId=${input.goalId}. Pass the goalId of the behavior's parent goal.`
176
+ hint: `Behavior id=${input.behaviorId} belongs to goal ${behaviorOpt.value.goalId}, not the requested goalId=${input.goalId}. Pass the goalId of the behavior's parent goal.`
179
177
  }
180
178
  };
181
179
  }
@@ -215,7 +213,7 @@ const handlePhaseTransitionRequest = (input) => Effect.gen(function* () {
215
213
  remediation: {
216
214
  suggestedTool: "run_tests",
217
215
  suggestedArgs: {},
218
- 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
216
+ hint: 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
219
217
  }
220
218
  };
221
219
  }
@@ -231,7 +229,7 @@ const handlePhaseTransitionRequest = (input) => Effect.gen(function* () {
231
229
  remediation: {
232
230
  suggestedTool: "run_tests",
233
231
  suggestedArgs: {},
234
- humanHint: `Cited artifact id ${resolvedArtifactId} does not exist. Artifacts are recorded by hooks observing your tool calls (Decision D7), so run the test (e.g. via the run_tests MCP tool) or make the file edit first; the post-tool-use hook will write the matching tdd_artifacts row and return its id, which can then be cited here.`
232
+ hint: `Cited artifact id ${resolvedArtifactId} does not exist. Artifacts are recorded by hooks observing your tool calls (Decision D7), so run the test (e.g. via the run_tests MCP tool) or make the file edit first; the post-tool-use hook will write the matching tdd_artifacts row and return its id, which can then be cited here.`
235
233
  }
236
234
  };
237
235
  citedArtifact = artifactOpt.value;
@@ -257,7 +255,16 @@ const handlePhaseTransitionRequest = (input) => Effect.gen(function* () {
257
255
  cited_artifact: citedArtifact,
258
256
  requested_behavior_id: input.behaviorId ?? null
259
257
  });
260
- if (!result.accepted) return result;
258
+ if (!result.accepted) {
259
+ const { humanHint, ...remediation } = result.remediation;
260
+ return {
261
+ ...result,
262
+ remediation: {
263
+ ...remediation,
264
+ hint: humanHint
265
+ }
266
+ };
267
+ }
261
268
  const out = yield* store.writeTddPhase({
262
269
  tddTaskId: input.tddTaskId,
263
270
  phase: result.phase,