398 lines
16 KiB
C++
398 lines
16 KiB
C++
// Step 615: whetstone_generate_taskitems MCP Tool (13 tests)
|
|
|
|
#include "MCPServer.h"
|
|
|
|
#include <iostream>
|
|
#include <string>
|
|
|
|
static int passed = 0, failed = 0;
|
|
#define TEST(name) { std::cout << " " << #name << "... "; }
|
|
#define PASS() { std::cout << "PASS\n"; ++passed; }
|
|
#define FAIL(msg) { std::cout << "FAIL: " << msg << "\n"; ++failed; }
|
|
#define CHECK(cond, msg) if (!(cond)) { FAIL(msg); return; } else {}
|
|
|
|
static json callTool(MCPServer& mcp, const std::string& name, const json& args) {
|
|
json req = {
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 1},
|
|
{"method", "tools/call"},
|
|
{"params", {{"name", name}, {"arguments", args}}}
|
|
};
|
|
json resp = mcp.handleRequest(req);
|
|
std::string text = resp["result"]["content"][0].value("text", "{}");
|
|
return json::parse(text);
|
|
}
|
|
|
|
static std::string clearSpec() {
|
|
return
|
|
"## Goals\n"
|
|
"- Ship intake parser\n"
|
|
"\n"
|
|
"## Constraints\n"
|
|
"- Keep modules header only\n"
|
|
"\n"
|
|
"## Dependencies\n"
|
|
"- nlohmann json\n"
|
|
"\n"
|
|
"## Acceptance Criteria\n"
|
|
"- tests pass\n";
|
|
}
|
|
|
|
static std::string riskySpec() {
|
|
return
|
|
"## Goals\n"
|
|
"- maybe improve intake and maybe improve review\n"
|
|
"\n"
|
|
"## Constraints\n"
|
|
"- Do not use dynamic allocation in hot path\n"
|
|
"- Use dynamic allocation in hot path for warmup\n"
|
|
"\n"
|
|
"## Dependencies\n"
|
|
"- maybe add cache lib\n"
|
|
"\n"
|
|
"## Acceptance Criteria\n"
|
|
"- maybe latency under 50ms\n";
|
|
}
|
|
|
|
static json intakeOutput(MCPServer& mcp, const std::string& markdown) {
|
|
return callTool(mcp, "whetstone_architect_intake", {{"markdown", markdown}});
|
|
}
|
|
|
|
static json taskitemArgsFromIntake(const json& intake) {
|
|
return {
|
|
{"normalizedRequirements", intake["normalizedRequirements"]},
|
|
{"conflicts", intake["conflicts"]}
|
|
};
|
|
}
|
|
|
|
void test_tool_registered_accessor() {
|
|
TEST(tool_registered_accessor);
|
|
MCPServer mcp;
|
|
bool found = false;
|
|
for (const auto& tool : mcp.getTools()) {
|
|
if (tool.name == "whetstone_generate_taskitems") found = true;
|
|
}
|
|
CHECK(found, "tool should be registered");
|
|
PASS();
|
|
}
|
|
|
|
void test_tool_schema_requires_normalized_requirements() {
|
|
TEST(tool_schema_requires_normalized_requirements);
|
|
MCPServer mcp;
|
|
bool required = false;
|
|
for (const auto& tool : mcp.getTools()) {
|
|
if (tool.name != "whetstone_generate_taskitems") continue;
|
|
for (const auto& req : tool.inputSchema["required"]) {
|
|
if (req == "normalizedRequirements") required = true;
|
|
}
|
|
}
|
|
CHECK(required, "normalizedRequirements should be required");
|
|
PASS();
|
|
}
|
|
|
|
void test_success_for_intake_output() {
|
|
TEST(success_for_intake_output);
|
|
MCPServer mcp;
|
|
json intake = intakeOutput(mcp, clearSpec());
|
|
json out = callTool(mcp, "whetstone_generate_taskitems", taskitemArgsFromIntake(intake));
|
|
CHECK(out.value("success", false), "success should be true");
|
|
PASS();
|
|
}
|
|
|
|
void test_returns_non_empty_tasks() {
|
|
TEST(returns_non_empty_tasks);
|
|
MCPServer mcp;
|
|
json intake = intakeOutput(mcp, clearSpec());
|
|
json out = callTool(mcp, "whetstone_generate_taskitems", taskitemArgsFromIntake(intake));
|
|
CHECK(out["tasks"].is_array() && !out["tasks"].empty(), "tasks should be non-empty array");
|
|
PASS();
|
|
}
|
|
|
|
void test_task_contains_annotation_fields() {
|
|
TEST(task_contains_annotation_fields);
|
|
MCPServer mcp;
|
|
json intake = intakeOutput(mcp, clearSpec());
|
|
json out = callTool(mcp, "whetstone_generate_taskitems", taskitemArgsFromIntake(intake));
|
|
const auto& task = out["tasks"][0];
|
|
CHECK(task.contains("confidence"), "confidence missing");
|
|
CHECK(task.contains("ambiguityCount"), "ambiguityCount missing");
|
|
CHECK(task.contains("escalate"), "escalate missing");
|
|
CHECK(task.contains("reasons"), "reasons missing");
|
|
PASS();
|
|
}
|
|
|
|
void test_conflict_count_roundtrips_from_input() {
|
|
TEST(conflict_count_roundtrips_from_input);
|
|
MCPServer mcp;
|
|
json intake = intakeOutput(mcp, riskySpec());
|
|
json out = callTool(mcp, "whetstone_generate_taskitems", taskitemArgsFromIntake(intake));
|
|
CHECK(out.value("conflictCount", 0) >= 1, "expected conflict count >= 1");
|
|
PASS();
|
|
}
|
|
|
|
void test_risky_input_triggers_escalation() {
|
|
TEST(risky_input_triggers_escalation);
|
|
MCPServer mcp;
|
|
json intake = intakeOutput(mcp, riskySpec());
|
|
json out = callTool(mcp, "whetstone_generate_taskitems", taskitemArgsFromIntake(intake));
|
|
CHECK(out.value("escalateCount", 0) >= 1, "expected escalations");
|
|
PASS();
|
|
}
|
|
|
|
void test_clear_input_has_non_escalated_task() {
|
|
TEST(clear_input_has_non_escalated_task);
|
|
MCPServer mcp;
|
|
json intake = intakeOutput(mcp, clearSpec());
|
|
json out = callTool(mcp, "whetstone_generate_taskitems", taskitemArgsFromIntake(intake));
|
|
bool foundNonEscalated = false;
|
|
for (const auto& task : out["tasks"]) {
|
|
if (!task.value("escalate", true)) foundNonEscalated = true;
|
|
}
|
|
CHECK(foundNonEscalated, "expected at least one non-escalated task");
|
|
PASS();
|
|
}
|
|
|
|
void test_missing_normalized_requirements_errors() {
|
|
TEST(missing_normalized_requirements_errors);
|
|
MCPServer mcp;
|
|
json out = callTool(mcp, "whetstone_generate_taskitems", json::object());
|
|
CHECK(!out.value("success", true), "success should be false");
|
|
CHECK(out.value("error", "") == "normalized_requirements_missing", "wrong error");
|
|
PASS();
|
|
}
|
|
|
|
void test_normalized_requirements_must_be_array() {
|
|
TEST(normalized_requirements_must_be_array);
|
|
MCPServer mcp;
|
|
json out = callTool(mcp, "whetstone_generate_taskitems",
|
|
{{"normalizedRequirements", "bad"}});
|
|
CHECK(!out.value("success", true), "success should be false");
|
|
CHECK(out.value("error", "") == "normalized_requirements_not_array", "wrong error");
|
|
PASS();
|
|
}
|
|
|
|
void test_rejects_empty_normalized_requirements() {
|
|
TEST(rejects_empty_normalized_requirements);
|
|
MCPServer mcp;
|
|
json out = callTool(mcp, "whetstone_generate_taskitems",
|
|
{{"normalizedRequirements", json::array()}});
|
|
CHECK(!out.value("success", true), "success should be false");
|
|
CHECK(out.value("error", "") == "normalized_requirements_empty", "wrong error");
|
|
PASS();
|
|
}
|
|
|
|
void test_rejects_invalid_requirement_kind() {
|
|
TEST(rejects_invalid_requirement_kind);
|
|
MCPServer mcp;
|
|
json bad = json::array({{
|
|
{"requirementId", "goal-1"},
|
|
{"kind", "invalid-kind"},
|
|
{"normalizedText", "ship it"},
|
|
{"anchor", "goals"},
|
|
{"sourceLine", 2},
|
|
{"ambiguous", false}
|
|
}});
|
|
json out = callTool(mcp, "whetstone_generate_taskitems",
|
|
{{"normalizedRequirements", bad}});
|
|
CHECK(!out.value("success", true), "success should be false");
|
|
CHECK(out.value("error", "") == "requirement_kind_invalid", "wrong error");
|
|
PASS();
|
|
}
|
|
|
|
void test_cross_project_repo_signal_grounds_target_files() {
|
|
TEST(cross_project_repo_signal_grounds_target_files);
|
|
MCPServer mcp;
|
|
json reqs = json::array({{
|
|
{"requirementId", "goal-constcad"},
|
|
{"kind", "goal"},
|
|
{"normalizedText", "ConstCAD ecosystem integration across cad_orchestrator, hivemind, gleaner, and constcad"},
|
|
{"anchor", "goals"},
|
|
{"sourceLine", 1},
|
|
{"ambiguous", false}
|
|
}});
|
|
json out = callTool(mcp, "whetstone_generate_taskitems",
|
|
{{"normalizedRequirements", reqs}, {"strictExecutionContract", true}});
|
|
CHECK(out.value("success", false), "success should be true");
|
|
const auto& files = out["tasks"][0]["executionContract"]["targetFiles"];
|
|
bool hasExternal = false;
|
|
for (const auto& f : files) {
|
|
if (!f.is_string()) continue;
|
|
std::string v = f.get<std::string>();
|
|
if (v.rfind("cad_orchestrator/", 0) == 0 ||
|
|
v.rfind("hivemind/", 0) == 0 ||
|
|
v.rfind("gleaner/", 0) == 0 ||
|
|
v.rfind("constcad/", 0) == 0) {
|
|
hasExternal = true;
|
|
}
|
|
CHECK(v.rfind("editor/src/mcp/", 0) != 0, "should not fallback to editor/src/mcp internal defaults");
|
|
}
|
|
CHECK(hasExternal, "expected at least one cross-project target file");
|
|
PASS();
|
|
}
|
|
|
|
void test_project_requirement_generates_specific_task_title_and_files() {
|
|
TEST(project_requirement_generates_specific_task_title_and_files);
|
|
MCPServer mcp;
|
|
json reqs = json::array({{
|
|
{"requirementId", "goal-guidance-bridge"},
|
|
{"kind", "goal"},
|
|
{"normalizedText", "Implement GuidanceBridge formulas in WHIMP/software/cpp/src/guidance/GuidanceBridge.cpp and WHIMP/software/cpp/src/guidance/GuidanceBridge.h"},
|
|
{"anchor", "goals"},
|
|
{"sourceLine", 1},
|
|
{"ambiguous", false}
|
|
}});
|
|
json out = callTool(mcp, "whetstone_generate_taskitems",
|
|
{{"normalizedRequirements", reqs}, {"strictExecutionContract", true}});
|
|
CHECK(out.value("success", false), "success should be true");
|
|
CHECK(out["tasks"].is_array() && !out["tasks"].empty(), "tasks should be non-empty");
|
|
const auto& task = out["tasks"][0];
|
|
std::string title = task.value("title", "");
|
|
std::string intent = task.value("intent", "");
|
|
CHECK(title.find("GuidanceBridge") != std::string::npos, "title should mention GuidanceBridge");
|
|
CHECK(title.find("Intake Foundation") == std::string::npos, "title should not fallback to generic milestone wording");
|
|
CHECK(intent.find("GuidanceBridge") != std::string::npos, "intent should preserve requirement text");
|
|
const auto& files = task["executionContract"]["targetFiles"];
|
|
bool hasCpp = false;
|
|
bool hasHeader = false;
|
|
for (const auto& f : files) {
|
|
if (!f.is_string()) continue;
|
|
std::string v = f.get<std::string>();
|
|
if (v == "WHIMP/software/cpp/src/guidance/GuidanceBridge.cpp") hasCpp = true;
|
|
if (v == "WHIMP/software/cpp/src/guidance/GuidanceBridge.h") hasHeader = true;
|
|
}
|
|
CHECK(hasCpp, "expected cpp target file");
|
|
CHECK(hasHeader, "expected header target file");
|
|
PASS();
|
|
}
|
|
|
|
void test_single_project_product_name_does_not_trigger_cross_repo_fallback() {
|
|
TEST(single_project_product_name_does_not_trigger_cross_repo_fallback);
|
|
MCPServer mcp;
|
|
json reqs = json::array({{
|
|
{"requirementId", "goal-hivemind-shell-onboarding"},
|
|
{"kind", "goal"},
|
|
{"normalizedText", "Add onboarding plan for installing hivemind_shell in docs/onboarding/hivemind_shell_ubuntu.md"},
|
|
{"anchor", "requirements"},
|
|
{"sourceLine", 1},
|
|
{"ambiguous", false}
|
|
}});
|
|
json out = callTool(mcp, "whetstone_generate_taskitems",
|
|
{{"normalizedRequirements", reqs}, {"strictExecutionContract", true}});
|
|
CHECK(out.value("success", false), "success should be true");
|
|
CHECK(out["tasks"].is_array() && !out["tasks"].empty(), "tasks should be non-empty");
|
|
const auto& files = out["tasks"][0]["executionContract"]["targetFiles"];
|
|
bool hasLocalDoc = false;
|
|
for (const auto& f : files) {
|
|
if (!f.is_string()) continue;
|
|
std::string v = f.get<std::string>();
|
|
if (v == "docs/onboarding/hivemind_shell_ubuntu.md") hasLocalDoc = true;
|
|
CHECK(v != "hivemind/README.md", "single-project product name should not trigger cross-repo fallback");
|
|
}
|
|
CHECK(hasLocalDoc, "expected local onboarding doc target");
|
|
PASS();
|
|
}
|
|
|
|
void test_strict_contract_infers_test_files_and_verification_type_for_code_work() {
|
|
TEST(strict_contract_infers_test_files_and_verification_type_for_code_work);
|
|
MCPServer mcp;
|
|
json reqs = json::array({{
|
|
{"requirementId", "goal-guidance-bridge-tests"},
|
|
{"kind", "goal"},
|
|
{"normalizedText", "Implement GuidanceBridge formulas in WHIMP/software/cpp/src/guidance/GuidanceBridge.cpp and WHIMP/software/cpp/src/guidance/GuidanceBridge.h"},
|
|
{"anchor", "goals"},
|
|
{"sourceLine", 1},
|
|
{"ambiguous", false}
|
|
}});
|
|
json out = callTool(mcp, "whetstone_generate_taskitems",
|
|
{{"normalizedRequirements", reqs}, {"strictExecutionContract", true}});
|
|
CHECK(out.value("success", false), "success should be true");
|
|
CHECK(out["tasks"].is_array() && !out["tasks"].empty(), "tasks should be non-empty");
|
|
const auto& contract = out["tasks"][0]["executionContract"];
|
|
CHECK(contract.value("verificationType", "") == "unit", "expected unit verification type");
|
|
const auto& tests = contract["testFiles"];
|
|
CHECK(tests.is_array() && !tests.empty(), "expected inferred test files");
|
|
bool hasGuidanceBridgeTest = false;
|
|
for (const auto& testFile : tests) {
|
|
if (!testFile.is_string()) continue;
|
|
if (testFile.get<std::string>() == "WHIMP/software/cpp/tests/guidance/GuidanceBridge_test.cpp") {
|
|
hasGuidanceBridgeTest = true;
|
|
}
|
|
}
|
|
CHECK(hasGuidanceBridgeTest, "expected inferred GuidanceBridge C++ test path");
|
|
PASS();
|
|
}
|
|
|
|
void test_rust_repo_contract_drops_wrong_language_commands_and_uses_real_test_file() {
|
|
TEST(rust_repo_contract_drops_wrong_language_commands_and_uses_real_test_file);
|
|
MCPServer mcp;
|
|
json reqs = json::array({
|
|
{
|
|
{"requirementId", "goal-rust-1"},
|
|
{"kind", "goal"},
|
|
{"normalizedText", "Implement control-plane recomposition in crates/whimpwm/src/render.rs and crates/whimpwm/src/presentation.rs"},
|
|
{"sourceText", "Implement control-plane recomposition in `crates/whimpwm/src/render.rs` and `crates/whimpwm/src/presentation.rs`"},
|
|
{"anchor", "goals"},
|
|
{"sourceLine", 1},
|
|
{"ambiguous", false}
|
|
},
|
|
{
|
|
{"requirementId", "constraint-rust-1"},
|
|
{"kind", "constraint"},
|
|
{"normalizedText", "Do not generate or verify go tasks for this sprint"},
|
|
{"sourceText", "Do not generate or verify Go tasks for this sprint."},
|
|
{"anchor", "constraints"},
|
|
{"sourceLine", 2},
|
|
{"ambiguous", false}
|
|
}
|
|
});
|
|
json out = callTool(mcp, "whetstone_generate_taskitems",
|
|
{{"normalizedRequirements", reqs},
|
|
{"strictExecutionContract", true},
|
|
{"workspace", "/home/bill/Documents/whimpwm"}});
|
|
CHECK(out.value("success", false), "success should be true");
|
|
CHECK(out["tasks"].is_array() && !out["tasks"].empty(), "tasks should be non-empty");
|
|
const auto& contract = out["tasks"][0]["executionContract"];
|
|
bool sawGo = false;
|
|
bool sawCargo = false;
|
|
for (const auto& commandJson : contract["acceptanceCommands"]) {
|
|
if (!commandJson.is_string()) continue;
|
|
const std::string command = commandJson.get<std::string>();
|
|
if (command.find("go test") != std::string::npos) sawGo = true;
|
|
if (command.find("cargo ") != std::string::npos) sawCargo = true;
|
|
}
|
|
CHECK(!sawGo, "go test should be removed for Rust repo");
|
|
CHECK(sawCargo, "cargo command should be present for Rust repo");
|
|
bool sawMainTest = false;
|
|
for (const auto& testFileJson : contract["testFiles"]) {
|
|
if (!testFileJson.is_string()) continue;
|
|
if (testFileJson.get<std::string>() == "crates/whimpwm/tests/main_test.rs") sawMainTest = true;
|
|
}
|
|
CHECK(sawMainTest, "expected real whimpwm Rust test file");
|
|
PASS();
|
|
}
|
|
|
|
int main() {
|
|
std::cout << "Step 615: whetstone_generate_taskitems MCP Tool\n";
|
|
|
|
test_tool_registered_accessor(); // 1
|
|
test_tool_schema_requires_normalized_requirements(); // 2
|
|
test_success_for_intake_output(); // 3
|
|
test_returns_non_empty_tasks(); // 4
|
|
test_task_contains_annotation_fields(); // 5
|
|
test_conflict_count_roundtrips_from_input(); // 6
|
|
test_risky_input_triggers_escalation(); // 7
|
|
test_clear_input_has_non_escalated_task(); // 8
|
|
test_missing_normalized_requirements_errors(); // 9
|
|
test_normalized_requirements_must_be_array(); // 10
|
|
test_rejects_empty_normalized_requirements(); // 11
|
|
test_rejects_invalid_requirement_kind(); // 12
|
|
test_cross_project_repo_signal_grounds_target_files(); // 13
|
|
test_project_requirement_generates_specific_task_title_and_files(); // 14
|
|
test_single_project_product_name_does_not_trigger_cross_repo_fallback(); // 15
|
|
test_strict_contract_infers_test_files_and_verification_type_for_code_work(); // 16
|
|
test_rust_repo_contract_drops_wrong_language_commands_and_uses_real_test_file(); // 17
|
|
|
|
std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n";
|
|
return failed == 0 ? 0 : 1;
|
|
}
|