@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.
- package/README.md +7 -6
- package/annotations.js +18 -0
- package/bin/vitest-agent-mcp.js +5 -140
- package/{middleware/idempotency.js → idempotency.js} +48 -49
- package/index.d.ts +5610 -1549
- package/index.js +35 -17
- package/main.d.ts +31 -0
- package/main.js +144 -0
- package/package.json +9 -6
- package/prompts/layer.js +108 -0
- package/register-toolkit.js +311 -0
- package/server.js +24 -894
- package/{context.js → session.js} +38 -22
- package/toolkit.js +86 -0
- package/tools/acceptance-metrics.js +26 -14
- package/tools/cache-health.js +28 -17
- package/tools/commit-changes.js +33 -10
- package/tools/configure.js +33 -10
- package/tools/coverage.js +34 -5
- package/tools/errors.js +36 -26
- package/tools/failure-signature-get.js +33 -10
- package/tools/file-coverage.js +37 -13
- package/tools/help.js +45 -4
- package/tools/history.js +39 -19
- package/tools/hypothesis.js +118 -102
- package/tools/inventory.js +149 -146
- package/tools/note.js +131 -113
- package/tools/overview.js +33 -10
- package/tools/ping.js +22 -10
- package/tools/register-agent.js +88 -62
- package/tools/run-tests.js +76 -23
- package/tools/settings-list.js +27 -10
- package/tools/status.js +35 -10
- package/tools/tdd-artifact.js +37 -22
- package/tools/tdd-behavior.js +120 -108
- package/tools/tdd-goal.js +98 -85
- package/tools/tdd-phase-transition-request.js +201 -166
- package/tools/tdd-progress-push.js +102 -0
- package/tools/tdd-task.js +140 -138
- package/tools/test.js +152 -138
- package/tools/trends.js +37 -18
- package/tools/triage-brief.js +34 -15
- package/tools/turn-search.js +39 -16
- package/tools/wrapup-prompt.js +37 -17
- package/utils/crash-guards.js +0 -22
- package/utils/replay-marker.js +12 -0
- package/utils/safe-format-fatal-error.js +0 -16
- package/utils/tool-error-envelope.js +3 -3
- package/version.js +13 -0
- package/layers/McpLive.js +0 -29
- package/prompts/index.js +0 -89
- package/router.js +0 -74
- package/session-env.js +0 -112
- package/utils/effect-to-zod.js +0 -158
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { RenderText } from "../annotations.js";
|
|
2
2
|
import { Effect, Option, Schema } from "effect";
|
|
3
|
-
import { DataReader, DataStore
|
|
3
|
+
import { DataReader, DataStore } from "@vitest-agent/engine";
|
|
4
|
+
import { Tool } from "effect/unstable/ai";
|
|
5
|
+
import { requiredArtifactForTransition, transitionEnforcesBehaviorMatch, validatePhaseTransition } from "@vitest-agent/sdk";
|
|
4
6
|
|
|
5
7
|
//#region src/tools/tdd-phase-transition-request.ts
|
|
6
8
|
/**
|
|
@@ -68,198 +70,231 @@ const PhaseTransitionDenied = Schema.Struct({
|
|
|
68
70
|
denialReason: denialReasonLiteral.annotate({ description: "Categorical refusal reason; see `remediation` for the suggested fix." }),
|
|
69
71
|
remediation: RemediationSchema
|
|
70
72
|
}).annotate({ identifier: "PhaseTransitionDenied" });
|
|
73
|
+
/**
|
|
74
|
+
* The `tdd_phase_transition_request` tool's success payload.
|
|
75
|
+
*
|
|
76
|
+
* @public
|
|
77
|
+
*/
|
|
71
78
|
const PhaseTransitionResult = Schema.Union([PhaseTransitionAccepted, PhaseTransitionDenied]).annotate({
|
|
72
79
|
identifier: "PhaseTransitionResult",
|
|
73
80
|
title: "tdd_phase_transition_request result",
|
|
74
81
|
description: "Discriminate on `accepted`. Acceptance carries the new phaseId; denial carries a typed reason and a remediation pointer."
|
|
75
82
|
});
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The `tdd_phase_transition_request` tool's parameters.
|
|
85
|
+
*
|
|
86
|
+
* `citedArtifactId` is optional in 2.0+. When omitted, the tool resolves
|
|
87
|
+
* the most recent matching artifact for this session, where "matching" is
|
|
88
|
+
* either the explicit `citedArtifactKind` value, or the kind required by
|
|
89
|
+
* the transition (per `requiredArtifactForTransition`), or "no artifact
|
|
90
|
+
* needed" for transitions like `spike→red` where the validator's
|
|
91
|
+
* `requiredArtifactForTransition` returns null. The accepted response
|
|
92
|
+
* carries `citedArtifactId` so the caller can see which row was used.
|
|
93
|
+
*
|
|
94
|
+
* @public
|
|
95
|
+
*/
|
|
96
|
+
const PhaseTransitionInput = Schema.Struct({
|
|
97
|
+
tddTaskId: Schema.Finite.annotate({ description: "tdd_tasks.id" }),
|
|
98
|
+
goalId: Schema.Finite.annotate({ description: "tdd_session_goals.id (required; goal must be in_progress)" }),
|
|
99
|
+
requestedPhase: phaseLiteral.annotate({ description: "Phase to transition to" }),
|
|
100
|
+
citedArtifactId: Schema.optionalKey(Schema.Finite).annotate({ description: "tdd_artifacts.id supplying the evidence. Optional — auto-resolved when omitted." }),
|
|
101
|
+
citedArtifactKind: Schema.optionalKey(artifactKindLiteral).annotate({ description: "Kind to look up when citedArtifactId is omitted (defaults to the kind required by the transition)." }),
|
|
102
|
+
behaviorId: Schema.optionalKey(Schema.Finite).annotate({ description: "tdd_session_behaviors.id when transitioning a specific behavior (must belong to goalId)" }),
|
|
103
|
+
reason: Schema.optionalKey(Schema.String).annotate({ description: "Free-text reason for the transition" })
|
|
104
|
+
});
|
|
105
|
+
/**
|
|
106
|
+
* Handler for {@link tddPhaseTransitionRequestTool}.
|
|
107
|
+
*
|
|
108
|
+
* @public
|
|
109
|
+
*/
|
|
110
|
+
const handlePhaseTransitionRequest = (input) => Effect.gen(function* () {
|
|
111
|
+
const reader = yield* DataReader;
|
|
112
|
+
const store = yield* DataStore;
|
|
113
|
+
const currentOpt = yield* reader.getCurrentTddPhase(input.tddTaskId);
|
|
114
|
+
const currentPhase = Option.isSome(currentOpt) ? currentOpt.value.phase : "spike";
|
|
115
|
+
const phaseStartedAt = Option.isSome(currentOpt) ? currentOpt.value.startedAt : (/* @__PURE__ */ new Date()).toISOString();
|
|
116
|
+
const currentPhaseId = Option.isSome(currentOpt) ? currentOpt.value.id : null;
|
|
117
|
+
const goalOpt = yield* reader.getGoalById(input.goalId);
|
|
118
|
+
if (Option.isNone(goalOpt)) return {
|
|
119
|
+
accepted: false,
|
|
120
|
+
phase: currentPhase,
|
|
121
|
+
denialReason: "goal_not_found",
|
|
122
|
+
remediation: {
|
|
123
|
+
suggestedTool: "tdd_goal",
|
|
124
|
+
suggestedArgs: {
|
|
125
|
+
action: "list",
|
|
126
|
+
tddTaskId: input.tddTaskId
|
|
127
|
+
},
|
|
128
|
+
humanHint: `No tdd_session_goals row with id=${input.goalId}. Call tdd_goal({ action: "list" }) to find the correct goal id.`
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
if (goalOpt.value.sessionId !== input.tddTaskId) return {
|
|
132
|
+
accepted: false,
|
|
133
|
+
phase: currentPhase,
|
|
134
|
+
denialReason: "goal_not_in_tdd_task",
|
|
135
|
+
remediation: {
|
|
136
|
+
suggestedTool: "tdd_goal",
|
|
137
|
+
suggestedArgs: {
|
|
138
|
+
action: "list",
|
|
139
|
+
tddTaskId: input.tddTaskId
|
|
140
|
+
},
|
|
141
|
+
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.`
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
if (goalOpt.value.status !== "in_progress") return {
|
|
145
|
+
accepted: false,
|
|
146
|
+
phase: currentPhase,
|
|
147
|
+
denialReason: "goal_not_in_progress",
|
|
148
|
+
remediation: {
|
|
149
|
+
suggestedTool: "tdd_goal_update",
|
|
150
|
+
suggestedArgs: {
|
|
151
|
+
id: input.goalId,
|
|
152
|
+
status: "in_progress"
|
|
153
|
+
},
|
|
154
|
+
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.`
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
if (input.behaviorId !== void 0) {
|
|
158
|
+
const behaviorOpt = yield* reader.getBehaviorById(input.behaviorId);
|
|
159
|
+
if (Option.isNone(behaviorOpt)) return {
|
|
107
160
|
accepted: false,
|
|
108
161
|
phase: currentPhase,
|
|
109
|
-
denialReason: "
|
|
162
|
+
denialReason: "behavior_not_found",
|
|
110
163
|
remediation: {
|
|
111
|
-
suggestedTool: "
|
|
164
|
+
suggestedTool: "tdd_behavior_list",
|
|
112
165
|
suggestedArgs: {
|
|
113
|
-
|
|
114
|
-
|
|
166
|
+
scope: "goal",
|
|
167
|
+
goalId: input.goalId
|
|
115
168
|
},
|
|
116
|
-
humanHint: `
|
|
169
|
+
humanHint: `No tdd_session_behaviors row with id=${input.behaviorId}. Call tdd_behavior_list to find the correct behavior id.`
|
|
117
170
|
}
|
|
118
171
|
};
|
|
119
|
-
if (
|
|
172
|
+
if (behaviorOpt.value.goalId !== input.goalId) return {
|
|
120
173
|
accepted: false,
|
|
121
174
|
phase: currentPhase,
|
|
122
|
-
denialReason: "
|
|
175
|
+
denialReason: "behavior_not_in_goal",
|
|
123
176
|
remediation: {
|
|
124
|
-
suggestedTool: "
|
|
125
|
-
suggestedArgs: {
|
|
126
|
-
|
|
127
|
-
status: "in_progress"
|
|
128
|
-
},
|
|
129
|
-
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.`
|
|
177
|
+
suggestedTool: "tdd_behavior_get",
|
|
178
|
+
suggestedArgs: { id: input.behaviorId },
|
|
179
|
+
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.`
|
|
130
180
|
}
|
|
131
181
|
};
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const requiredKindForTransition = requiredArtifactForTransition(currentPhase, input.requestedPhase);
|
|
159
|
-
let resolvedArtifactId;
|
|
160
|
-
let resolvedKindSource = "none";
|
|
161
|
-
let kindToLookUp;
|
|
162
|
-
if (input.citedArtifactId !== void 0) {
|
|
163
|
-
resolvedArtifactId = input.citedArtifactId;
|
|
164
|
-
resolvedKindSource = "explicit-id";
|
|
165
|
-
} else if (input.citedArtifactKind !== void 0) {
|
|
166
|
-
kindToLookUp = input.citedArtifactKind;
|
|
167
|
-
resolvedKindSource = "explicit-kind";
|
|
168
|
-
} else if (requiredKindForTransition !== null) {
|
|
169
|
-
kindToLookUp = requiredKindForTransition.kind;
|
|
170
|
-
resolvedKindSource = "transition-derived";
|
|
171
|
-
}
|
|
172
|
-
const scopeLookupByBehavior = input.behaviorId !== void 0 && transitionEnforcesBehaviorMatch(currentPhase, input.requestedPhase);
|
|
173
|
-
if (resolvedArtifactId === void 0 && kindToLookUp !== void 0) {
|
|
174
|
-
const recent = yield* reader.listTddArtifactsForTask({
|
|
182
|
+
}
|
|
183
|
+
const requiredKindForTransition = requiredArtifactForTransition(currentPhase, input.requestedPhase);
|
|
184
|
+
let resolvedArtifactId;
|
|
185
|
+
let resolvedKindSource = "none";
|
|
186
|
+
let kindToLookUp;
|
|
187
|
+
if (input.citedArtifactId !== void 0) {
|
|
188
|
+
resolvedArtifactId = input.citedArtifactId;
|
|
189
|
+
resolvedKindSource = "explicit-id";
|
|
190
|
+
} else if (input.citedArtifactKind !== void 0) {
|
|
191
|
+
kindToLookUp = input.citedArtifactKind;
|
|
192
|
+
resolvedKindSource = "explicit-kind";
|
|
193
|
+
} else if (requiredKindForTransition !== null) {
|
|
194
|
+
kindToLookUp = requiredKindForTransition.kind;
|
|
195
|
+
resolvedKindSource = "transition-derived";
|
|
196
|
+
}
|
|
197
|
+
const scopeLookupByBehavior = input.behaviorId !== void 0 && transitionEnforcesBehaviorMatch(currentPhase, input.requestedPhase);
|
|
198
|
+
if (resolvedArtifactId === void 0 && kindToLookUp !== void 0) {
|
|
199
|
+
const recent = yield* reader.listTddArtifactsForTask({
|
|
200
|
+
tddTaskId: input.tddTaskId,
|
|
201
|
+
artifactKind: kindToLookUp,
|
|
202
|
+
...scopeLookupByBehavior && { behaviorId: input.behaviorId },
|
|
203
|
+
limit: 1
|
|
204
|
+
});
|
|
205
|
+
if (recent.length === 0) {
|
|
206
|
+
const sinceIso = (/* @__PURE__ */ new Date(Date.now() - DIAGNOSTIC_WINDOW_MINUTES * 6e4)).toISOString();
|
|
207
|
+
const crossSessionCount = yield* reader.countRecentArtifactsInOtherSessionsOfConversation({
|
|
175
208
|
tddTaskId: input.tddTaskId,
|
|
176
|
-
|
|
177
|
-
...scopeLookupByBehavior && { behaviorId: input.behaviorId },
|
|
178
|
-
limit: 1
|
|
209
|
+
sinceIso
|
|
179
210
|
});
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const crossSessionCount = yield* reader.countRecentArtifactsInOtherSessionsOfConversation({
|
|
183
|
-
tddTaskId: input.tddTaskId,
|
|
184
|
-
sinceIso
|
|
185
|
-
});
|
|
186
|
-
const baseHint = `No '${kindToLookUp}' artifact has been recorded for tdd_task ${input.tddTaskId}. Artifacts are recorded by hooks observing your tool calls (Decision D7) — run the test (e.g. via run_tests) or make the file edit first; the post-tool-use hook will write the matching tdd_artifacts row and the next call to this tool will pick it up automatically.`;
|
|
187
|
-
return {
|
|
188
|
-
accepted: false,
|
|
189
|
-
phase: currentPhase,
|
|
190
|
-
denialReason: "missing_artifact_evidence",
|
|
191
|
-
remediation: {
|
|
192
|
-
suggestedTool: "run_tests",
|
|
193
|
-
suggestedArgs: {},
|
|
194
|
-
humanHint: crossSessionCount > 0 ? `${baseHint} ${crossSessionCount} artifact${crossSessionCount === 1 ? " was" : "s were"} recorded under a different session of this conversation in the last ${DIAGNOSTIC_WINDOW_MINUTES} minutes — the subagent's hooks may be attributing to a detached session; set VITEST_AGENT_TDD_TASK_ID=${input.tddTaskId} in the subagent's environment or dispatch it as an unnamed background subagent.` : baseHint
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
resolvedArtifactId = recent[0].id;
|
|
199
|
-
}
|
|
200
|
-
let citedArtifact;
|
|
201
|
-
if (resolvedArtifactId !== void 0) {
|
|
202
|
-
const artifactOpt = yield* reader.getTddArtifactWithContext(resolvedArtifactId);
|
|
203
|
-
if (Option.isNone(artifactOpt)) return {
|
|
211
|
+
const baseHint = `No '${kindToLookUp}' artifact has been recorded for tdd_task ${input.tddTaskId}. Artifacts are recorded by hooks observing your tool calls (Decision D7) — run the test (e.g. via run_tests) or make the file edit first; the post-tool-use hook will write the matching tdd_artifacts row and the next call to this tool will pick it up automatically.`;
|
|
212
|
+
return {
|
|
204
213
|
accepted: false,
|
|
205
214
|
phase: currentPhase,
|
|
206
215
|
denialReason: "missing_artifact_evidence",
|
|
207
216
|
remediation: {
|
|
208
217
|
suggestedTool: "run_tests",
|
|
209
218
|
suggestedArgs: {},
|
|
210
|
-
humanHint:
|
|
219
|
+
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
|
|
211
220
|
}
|
|
212
221
|
};
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
tdd_task_id: input.tddTaskId,
|
|
228
|
-
current_phase: currentPhase,
|
|
229
|
-
current_phase_id: currentPhaseId,
|
|
230
|
-
phase_started_at: phaseStartedAt,
|
|
231
|
-
now: (/* @__PURE__ */ new Date()).toISOString(),
|
|
232
|
-
requested_phase: input.requestedPhase,
|
|
233
|
-
cited_artifact: citedArtifact,
|
|
234
|
-
requested_behavior_id: input.behaviorId ?? null
|
|
235
|
-
});
|
|
236
|
-
if (!result.accepted) return result;
|
|
237
|
-
const out = yield* store.writeTddPhase({
|
|
238
|
-
tddTaskId: input.tddTaskId,
|
|
239
|
-
phase: result.phase,
|
|
240
|
-
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
241
|
-
...input.behaviorId !== void 0 && { behaviorId: input.behaviorId },
|
|
242
|
-
...input.reason !== void 0 && { transitionReason: input.reason }
|
|
243
|
-
});
|
|
244
|
-
if (input.behaviorId !== void 0) yield* Effect.gen(function* () {
|
|
245
|
-
const behOpt = yield* reader.getBehaviorById(input.behaviorId);
|
|
246
|
-
if (Option.isSome(behOpt) && behOpt.value.status === "pending") yield* store.updateBehavior({
|
|
247
|
-
id: input.behaviorId,
|
|
248
|
-
status: "in_progress"
|
|
249
|
-
});
|
|
250
|
-
}).pipe(Effect.catchCause((cause) => Effect.logDebug("behavior auto-promotion failed", cause)));
|
|
251
|
-
return {
|
|
252
|
-
accepted: true,
|
|
253
|
-
phase: result.phase,
|
|
254
|
-
newPhaseId: out.id,
|
|
255
|
-
previousPhaseId: out.previousPhaseId,
|
|
256
|
-
...resolvedArtifactId !== void 0 && {
|
|
257
|
-
citedArtifactId: resolvedArtifactId,
|
|
258
|
-
citedArtifactSource: resolvedKindSource
|
|
222
|
+
}
|
|
223
|
+
resolvedArtifactId = recent[0].id;
|
|
224
|
+
}
|
|
225
|
+
let citedArtifact;
|
|
226
|
+
if (resolvedArtifactId !== void 0) {
|
|
227
|
+
const artifactOpt = yield* reader.getTddArtifactWithContext(resolvedArtifactId);
|
|
228
|
+
if (Option.isNone(artifactOpt)) return {
|
|
229
|
+
accepted: false,
|
|
230
|
+
phase: currentPhase,
|
|
231
|
+
denialReason: "missing_artifact_evidence",
|
|
232
|
+
remediation: {
|
|
233
|
+
suggestedTool: "run_tests",
|
|
234
|
+
suggestedArgs: {},
|
|
235
|
+
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.`
|
|
259
236
|
}
|
|
260
237
|
};
|
|
261
|
-
|
|
262
|
-
}
|
|
238
|
+
citedArtifact = artifactOpt.value;
|
|
239
|
+
} else citedArtifact = {
|
|
240
|
+
id: -1,
|
|
241
|
+
phase_id: -1,
|
|
242
|
+
artifact_kind: "test_written",
|
|
243
|
+
test_case_id: null,
|
|
244
|
+
test_case_created_turn_at: null,
|
|
245
|
+
test_case_authored_in_session: false,
|
|
246
|
+
test_run_id: null,
|
|
247
|
+
test_first_failure_run_id: null,
|
|
248
|
+
behavior_id: null,
|
|
249
|
+
suite: "vitest"
|
|
250
|
+
};
|
|
251
|
+
const result = validatePhaseTransition({
|
|
252
|
+
tdd_task_id: input.tddTaskId,
|
|
253
|
+
current_phase: currentPhase,
|
|
254
|
+
current_phase_id: currentPhaseId,
|
|
255
|
+
phase_started_at: phaseStartedAt,
|
|
256
|
+
now: (/* @__PURE__ */ new Date()).toISOString(),
|
|
257
|
+
requested_phase: input.requestedPhase,
|
|
258
|
+
cited_artifact: citedArtifact,
|
|
259
|
+
requested_behavior_id: input.behaviorId ?? null
|
|
260
|
+
});
|
|
261
|
+
if (!result.accepted) return result;
|
|
262
|
+
const out = yield* store.writeTddPhase({
|
|
263
|
+
tddTaskId: input.tddTaskId,
|
|
264
|
+
phase: result.phase,
|
|
265
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
266
|
+
...input.behaviorId !== void 0 && { behaviorId: input.behaviorId },
|
|
267
|
+
...input.reason !== void 0 && { transitionReason: input.reason }
|
|
268
|
+
});
|
|
269
|
+
if (input.behaviorId !== void 0) yield* Effect.gen(function* () {
|
|
270
|
+
const behOpt = yield* reader.getBehaviorById(input.behaviorId);
|
|
271
|
+
if (Option.isSome(behOpt) && behOpt.value.status === "pending") yield* store.updateBehavior({
|
|
272
|
+
id: input.behaviorId,
|
|
273
|
+
status: "in_progress"
|
|
274
|
+
});
|
|
275
|
+
}).pipe(Effect.catchCause((cause) => Effect.logDebug("behavior auto-promotion failed", cause)));
|
|
276
|
+
return {
|
|
277
|
+
accepted: true,
|
|
278
|
+
phase: result.phase,
|
|
279
|
+
newPhaseId: out.id,
|
|
280
|
+
previousPhaseId: out.previousPhaseId,
|
|
281
|
+
...resolvedArtifactId !== void 0 && {
|
|
282
|
+
citedArtifactId: resolvedArtifactId,
|
|
283
|
+
citedArtifactSource: resolvedKindSource
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}).pipe(Effect.orDie);
|
|
287
|
+
/**
|
|
288
|
+
* The Effect-native `tdd_phase_transition_request` tool.
|
|
289
|
+
*
|
|
290
|
+
* @public
|
|
291
|
+
*/
|
|
292
|
+
const tddPhaseTransitionRequestTool = Tool.make("tdd_phase_transition_request", {
|
|
293
|
+
description: "Use when advancing a TDD cycle and you need a phase transition validated and recorded. Validates goal status, behavior↔goal membership, and D2 artifact-evidence binding rules; returns accept/deny. On accept, auto-promotes a behavior 'pending' → 'in_progress' when behaviorId is supplied. citedArtifactId is OPTIONAL — when omitted, the most recent matching artifact is auto-resolved (kind comes from citedArtifactKind if supplied, otherwise from the transition's required-evidence rule). Transitions like spike→red that require no artifact need neither field. The accepted response echoes citedArtifactId + citedArtifactSource so the caller can see which row was picked.",
|
|
294
|
+
parameters: PhaseTransitionInput,
|
|
295
|
+
success: PhaseTransitionResult,
|
|
296
|
+
dependencies: [DataReader, DataStore]
|
|
297
|
+
}).annotate(Tool.Title, "TDD phase transition request").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true).annotate(RenderText, (encoded) => JSON.stringify(encoded, null, 2));
|
|
263
298
|
|
|
264
299
|
//#endregion
|
|
265
|
-
export { PhaseTransitionResult,
|
|
300
|
+
export { PhaseTransitionInput, PhaseTransitionResult, handlePhaseTransitionRequest, tddPhaseTransitionRequestTool };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { RenderText } from "../annotations.js";
|
|
2
|
+
import { Effect, Option, Schema } from "effect";
|
|
3
|
+
import { DataReader } from "@vitest-agent/engine";
|
|
4
|
+
import { McpServer, Tool } from "effect/unstable/ai";
|
|
5
|
+
import { ChannelEvent } from "@vitest-agent/sdk";
|
|
6
|
+
|
|
7
|
+
//#region src/tools/tdd-progress-push.ts
|
|
8
|
+
/**
|
|
9
|
+
* The `tdd_progress_push` tool's parameters.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
const TddProgressPushInput = Schema.Struct({ payload: Schema.String.annotate({ description: "Pre-stringified ChannelEvent JSON (see schemas/ChannelEvent in @vitest-agent/sdk)" }) });
|
|
14
|
+
/**
|
|
15
|
+
* The `tdd_progress_push` result — always `{ ok: true }`.
|
|
16
|
+
*
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
const TddProgressPushResult = Schema.Struct({ ok: Schema.Literal(true) }).annotate({
|
|
20
|
+
identifier: "TddProgressPushResult",
|
|
21
|
+
title: "tdd_progress_push result",
|
|
22
|
+
description: "Always ok:true — the push is best-effort and never reports delivery."
|
|
23
|
+
});
|
|
24
|
+
/** The logger name every channel notification is published under. */
|
|
25
|
+
const CHANNEL_LOGGER = "vitest-agent/channel";
|
|
26
|
+
/**
|
|
27
|
+
* For behavior-scoped events, resolve goalId/sessionId server-side from
|
|
28
|
+
* behaviorId so a stale orchestrator context cannot push the wrong tree
|
|
29
|
+
* coordinates. Goal-scoped events get sessionId resolved from goalId.
|
|
30
|
+
* Returns the enriched event object or the original on resolution failure.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
const resolveChannelEvent = (raw) => Effect.gen(function* () {
|
|
35
|
+
const decoded = yield* Schema.decodeUnknownEffect(ChannelEvent)(raw).pipe(Effect.option);
|
|
36
|
+
if (Option.isNone(decoded)) return raw;
|
|
37
|
+
const event = decoded.value;
|
|
38
|
+
const reader = yield* DataReader;
|
|
39
|
+
return yield* Effect.gen(function* () {
|
|
40
|
+
switch (event.type) {
|
|
41
|
+
case "behavior_started":
|
|
42
|
+
case "phase_transition":
|
|
43
|
+
case "behavior_completed":
|
|
44
|
+
case "behavior_abandoned":
|
|
45
|
+
case "blocked": {
|
|
46
|
+
const goalIdOpt = yield* reader.resolveGoalIdForBehavior(event.behaviorId);
|
|
47
|
+
if (Option.isNone(goalIdOpt)) return event;
|
|
48
|
+
const goalDetailOpt = yield* reader.getGoalById(goalIdOpt.value);
|
|
49
|
+
if (Option.isNone(goalDetailOpt)) return event;
|
|
50
|
+
return {
|
|
51
|
+
...event,
|
|
52
|
+
goalId: goalIdOpt.value,
|
|
53
|
+
sessionId: goalDetailOpt.value.sessionId
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
case "goal_started":
|
|
57
|
+
case "goal_completed":
|
|
58
|
+
case "goal_abandoned": {
|
|
59
|
+
const goalDetailOpt = yield* reader.getGoalById(event.goalId);
|
|
60
|
+
if (Option.isNone(goalDetailOpt)) return event;
|
|
61
|
+
return {
|
|
62
|
+
...event,
|
|
63
|
+
sessionId: goalDetailOpt.value.sessionId
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
default: return event;
|
|
67
|
+
}
|
|
68
|
+
}).pipe(Effect.orElseSucceed(() => event));
|
|
69
|
+
});
|
|
70
|
+
/**
|
|
71
|
+
* Handler for {@link tddProgressPushTool}. Malformed JSON is forwarded as
|
|
72
|
+
* the raw string; a DB read failure forwards the decoded event unenriched;
|
|
73
|
+
* a notification failure (no initialized client, transport gone) is
|
|
74
|
+
* ignored. The result is `{ ok: true }` on every path.
|
|
75
|
+
*
|
|
76
|
+
* @public
|
|
77
|
+
*/
|
|
78
|
+
const handleTddProgressPush = (input) => Effect.gen(function* () {
|
|
79
|
+
const server = yield* McpServer.McpServer;
|
|
80
|
+
const parsed = yield* Effect.try(() => JSON.parse(input.payload)).pipe(Effect.option);
|
|
81
|
+
const data = Option.isSome(parsed) ? yield* resolveChannelEvent(parsed.value) : input.payload;
|
|
82
|
+
yield* server.notifications["notifications/message"]({
|
|
83
|
+
level: "info",
|
|
84
|
+
logger: CHANNEL_LOGGER,
|
|
85
|
+
data
|
|
86
|
+
}).pipe(Effect.ignore);
|
|
87
|
+
return { ok: true };
|
|
88
|
+
});
|
|
89
|
+
/**
|
|
90
|
+
* The Effect-native `tdd_progress_push` tool.
|
|
91
|
+
*
|
|
92
|
+
* @public
|
|
93
|
+
*/
|
|
94
|
+
const tddProgressPushTool = Tool.make("tdd_progress_push", {
|
|
95
|
+
description: "Use when a TDD orchestrator needs to report progress to the main agent over a Claude Code channel. The MCP server validates the payload against the ChannelEvent union and resolves goalId/sessionId server-side from behaviorId for behavior-scoped events (so a stale orchestrator context cannot push the wrong tree coordinates). Best-effort — returns { ok: true } regardless of whether channels are active.",
|
|
96
|
+
parameters: TddProgressPushInput,
|
|
97
|
+
success: TddProgressPushResult,
|
|
98
|
+
dependencies: [DataReader, McpServer.McpServer]
|
|
99
|
+
}).annotate(Tool.Title, "TDD progress push").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false).annotate(RenderText, (encoded) => JSON.stringify(encoded, null, 2));
|
|
100
|
+
|
|
101
|
+
//#endregion
|
|
102
|
+
export { CHANNEL_LOGGER, TddProgressPushInput, TddProgressPushResult, handleTddProgressPush, resolveChannelEvent, tddProgressPushTool };
|