diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 7a70eb9..04478b0 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -4468,4 +4468,13 @@ target_link_libraries(step620_test PRIVATE tree_sitter_javascript tree_sitter_typescript tree_sitter_java tree_sitter_rust tree_sitter_go) +add_executable(step621_test tests/step621_test.cpp) +target_include_directories(step621_test PRIVATE src) +target_link_libraries(step621_test PRIVATE + nlohmann_json::nlohmann_json + unofficial::tree-sitter::tree-sitter + tree_sitter_python tree_sitter_cpp tree_sitter_elisp + tree_sitter_javascript tree_sitter_typescript + tree_sitter_java tree_sitter_rust tree_sitter_go) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/AgentPermissionPolicy.h b/editor/src/AgentPermissionPolicy.h index facaf9f..e4972e3 100644 --- a/editor/src/AgentPermissionPolicy.h +++ b/editor/src/AgentPermissionPolicy.h @@ -80,6 +80,10 @@ struct AgentPermissionPolicy { return true; } + if (method == "setWorkspaceContext") { + return role == AgentRole::Refactor || role == AgentRole::Generator; + } + // Mutation methods: Refactor and Generator only if (method == "generateCode" || method == "applyMutation" || diff --git a/editor/src/headless_rpc/DispatchPart3.h b/editor/src/headless_rpc/DispatchPart3.h index 1edfa80..8e81fae 100644 --- a/editor/src/headless_rpc/DispatchPart3.h +++ b/editor/src/headless_rpc/DispatchPart3.h @@ -51,6 +51,30 @@ }); } + // --- setWorkspaceContext --- + if (method == "setWorkspaceContext") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string workspace = params.value("workspace", ""); + std::string language = params.value("language", ""); + if (workspace.empty()) { + return headlessRpcError(id, -32602, "Missing workspace"); + } + + state.workspaceRoot = workspace; + if (!language.empty()) state.defaultLanguage = language; + state.project.scanWorkspace(workspace); + const auto& idx = state.project.index; + return headlessRpcResult(id, { + {"workspace", state.workspaceRoot}, + {"language", state.defaultLanguage}, + {"fileCount", idx.fileCount()}, + {"dirCount", idx.dirCount()} + }); + } + // --- getImportGraph --- if (method == "getImportGraph") { auto params = request.contains("params") ? request["params"] diff --git a/editor/src/mcp/RegisterArchitectIntakeTools.h b/editor/src/mcp/RegisterArchitectIntakeTools.h index 4b87e5a..392c9ba 100644 --- a/editor/src/mcp/RegisterArchitectIntakeTools.h +++ b/editor/src/mcp/RegisterArchitectIntakeTools.h @@ -38,6 +38,20 @@ [this](const json& args) { return runQueueReady(args); }; + + tools_.push_back({"whetstone_set_workspace", + "Switch active workspace and default language without restarting the MCP server.", + {{"type", "object"}, {"properties", { + {"workspace", {{"type", "string"}, + {"description", "Workspace root path."}}}, + {"language", {{"type", "string"}, + {"description", "Default language for new operations."}}} + }}, {"required", json::array({"workspace", "language"})}} + }); + toolHandlers_["whetstone_set_workspace"] = + [this](const json& args) { + return runSetWorkspace(args); + }; } json runArchitectIntake(const json& args) { @@ -476,3 +490,63 @@ } return out; } + + json runSetWorkspace(const json& args) { + if (!args.contains("workspace")) { + return { + {"success", false}, + {"error", "workspace_missing"} + }; + } + if (!args.contains("language")) { + return { + {"success", false}, + {"error", "language_missing"} + }; + } + if (!args["workspace"].is_string()) { + return { + {"success", false}, + {"error", "workspace_not_string"} + }; + } + if (!args["language"].is_string()) { + return { + {"success", false}, + {"error", "language_not_string"} + }; + } + + const std::string workspace = args.value("workspace", ""); + const std::string language = args.value("language", ""); + if (workspace.empty()) { + return { + {"success", false}, + {"error", "workspace_empty"} + }; + } + if (language.empty()) { + return { + {"success", false}, + {"error", "language_empty"} + }; + } + + json result = callWhetstone("setWorkspaceContext", { + {"workspace", workspace}, + {"language", language} + }); + if (hasCallError(result)) { + return { + {"success", false}, + {"error", result["error"]} + }; + } + return { + {"success", true}, + {"workspace", result.value("workspace", workspace)}, + {"language", result.value("language", language)}, + {"fileCount", result.value("fileCount", 0)}, + {"dirCount", result.value("dirCount", 0)} + }; + } diff --git a/editor/tests/step621_test.cpp b/editor/tests/step621_test.cpp new file mode 100644 index 0000000..8c1fbae --- /dev/null +++ b/editor/tests/step621_test.cpp @@ -0,0 +1,229 @@ +// Step 621: whetstone_set_workspace MCP tool (12 tests) + +#include "HeadlessEditorState.h" +#include "MCPServer.h" + +#include +#include +#include +#include + +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::filesystem::path tempWorkspace() { + std::filesystem::path root = std::filesystem::temp_directory_path() / "whetstone_step621"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + return root; +} + +static void writeFile(const std::filesystem::path& path, const std::string& content) { + std::ofstream out(path); + out << content; +} + +void test_tool_registered_accessor() { + TEST(tool_registered_accessor); + MCPServer mcp; + bool found = false; + for (const auto& tool : mcp.getTools()) { + if (tool.name == "whetstone_set_workspace") found = true; + } + CHECK(found, "tool should be registered"); + PASS(); +} + +void test_tool_schema_requires_workspace() { + TEST(tool_schema_requires_workspace); + MCPServer mcp; + bool required = false; + for (const auto& tool : mcp.getTools()) { + if (tool.name != "whetstone_set_workspace") continue; + for (const auto& req : tool.inputSchema["required"]) { + if (req == "workspace") required = true; + } + } + CHECK(required, "workspace should be required"); + PASS(); +} + +void test_tool_schema_requires_language() { + TEST(tool_schema_requires_language); + MCPServer mcp; + bool required = false; + for (const auto& tool : mcp.getTools()) { + if (tool.name != "whetstone_set_workspace") continue; + for (const auto& req : tool.inputSchema["required"]) { + if (req == "language") required = true; + } + } + CHECK(required, "language should be required"); + PASS(); +} + +void test_missing_workspace_errors() { + TEST(missing_workspace_errors); + MCPServer mcp; + json out = callTool(mcp, "whetstone_set_workspace", {{"language", "cpp"}}); + CHECK(!out.value("success", true), "success should be false"); + CHECK(out.value("error", "") == "workspace_missing", "wrong error"); + PASS(); +} + +void test_missing_language_errors() { + TEST(missing_language_errors); + MCPServer mcp; + json out = callTool(mcp, "whetstone_set_workspace", {{"workspace", "/tmp/ws"}}); + CHECK(!out.value("success", true), "success should be false"); + CHECK(out.value("error", "") == "language_missing", "wrong error"); + PASS(); +} + +void test_workspace_must_be_string() { + TEST(workspace_must_be_string); + MCPServer mcp; + json out = callTool(mcp, "whetstone_set_workspace", + {{"workspace", 42}, {"language", "cpp"}}); + CHECK(!out.value("success", true), "success should be false"); + CHECK(out.value("error", "") == "workspace_not_string", "wrong error"); + PASS(); +} + +void test_language_must_be_string() { + TEST(language_must_be_string); + MCPServer mcp; + json out = callTool(mcp, "whetstone_set_workspace", + {{"workspace", "/tmp/ws"}, {"language", 42}}); + CHECK(!out.value("success", true), "success should be false"); + CHECK(out.value("error", "") == "language_not_string", "wrong error"); + PASS(); +} + +void test_handler_calls_set_workspace_context_method() { + TEST(handler_calls_set_workspace_context_method); + MCPServer mcp; + std::string capturedMethod; + json capturedParams; + mcp.setRpcCallback([&](const json& request) { + capturedMethod = request.value("method", ""); + capturedParams = request.value("params", json::object()); + return json{{"result", {{"workspace", "/tmp/a"}, {"language", "go"}, {"fileCount", 3}, {"dirCount", 1}}}}; + }); + + json out = callTool(mcp, "whetstone_set_workspace", + {{"workspace", "/tmp/a"}, {"language", "go"}}); + CHECK(out.value("success", false), "success should be true"); + CHECK(capturedMethod == "setWorkspaceContext", "wrong rpc method"); + CHECK(capturedParams.value("workspace", "") == "/tmp/a", "workspace not forwarded"); + CHECK(capturedParams.value("language", "") == "go", "language not forwarded"); + PASS(); +} + +void test_handler_returns_rpc_values() { + TEST(handler_returns_rpc_values); + MCPServer mcp; + mcp.setRpcCallback([&](const json&) { + return json{{"result", {{"workspace", "/tmp/custom"}, {"language", "rust"}, + {"fileCount", 9}, {"dirCount", 4}}}}; + }); + json out = callTool(mcp, "whetstone_set_workspace", + {{"workspace", "/tmp/a"}, {"language", "go"}}); + CHECK(out.value("workspace", "") == "/tmp/custom", "workspace mismatch"); + CHECK(out.value("language", "") == "rust", "language mismatch"); + CHECK(out.value("fileCount", 0) == 9, "fileCount mismatch"); + CHECK(out.value("dirCount", 0) == 4, "dirCount mismatch"); + PASS(); +} + +void test_handler_surfaces_rpc_error() { + TEST(handler_surfaces_rpc_error); + MCPServer mcp; + mcp.setRpcCallback([&](const json&) { + return json{{"error", {{"message", "bad workspace"}}}}; + }); + json out = callTool(mcp, "whetstone_set_workspace", + {{"workspace", "/tmp/a"}, {"language", "go"}}); + CHECK(!out.value("success", true), "success should be false"); + CHECK(out.value("error", "") == "bad workspace", "error message mismatch"); + PASS(); +} + +void test_integration_switches_headless_workspace_and_language() { + TEST(integration_switches_headless_workspace_and_language); + auto root = tempWorkspace(); + writeFile(root / "main.py", "print('ok')\n"); + + HeadlessEditorState state; + state.setAgentRole("mcp-session", AgentRole::Refactor); + MCPServer mcp; + mcp.setRpcCallback([&](const json& request) { + return state.processAgentRequest(request, "mcp-session"); + }); + + json out = callTool(mcp, "whetstone_set_workspace", + {{"workspace", root.string()}, {"language", "python"}}); + CHECK(out.value("success", false), "success should be true"); + CHECK(out.value("workspace", "") == root.string(), "workspace mismatch"); + CHECK(out.value("language", "") == "python", "language mismatch"); + PASS(); +} + +void test_integration_affects_workspace_list_tool() { + TEST(integration_affects_workspace_list_tool); + auto root = tempWorkspace(); + writeFile(root / "notes.txt", "hello\n"); + + HeadlessEditorState state; + state.setAgentRole("mcp-session", AgentRole::Refactor); + MCPServer mcp; + mcp.setRpcCallback([&](const json& request) { + return state.processAgentRequest(request, "mcp-session"); + }); + + (void)callTool(mcp, "whetstone_set_workspace", + {{"workspace", root.string()}, {"language", "python"}}); + json listed = callTool(mcp, "whetstone_workspace_list", json::object()); + CHECK(listed.contains("files"), "workspace list should return files"); + bool foundNotes = false; + for (const auto& item : listed["files"]) { + if (item.value("path", "") == "notes.txt") foundNotes = true; + } + CHECK(foundNotes, "expected notes.txt in workspace list"); + PASS(); +} + +int main() { + std::cout << "Step 621: whetstone_set_workspace MCP tool\n"; + + test_tool_registered_accessor(); // 1 + test_tool_schema_requires_workspace(); // 2 + test_tool_schema_requires_language(); // 3 + test_missing_workspace_errors(); // 4 + test_missing_language_errors(); // 5 + test_workspace_must_be_string(); // 6 + test_language_must_be_string(); // 7 + test_handler_calls_set_workspace_context_method(); // 8 + test_handler_returns_rpc_values(); // 9 + test_handler_surfaces_rpc_error(); // 10 + test_integration_switches_headless_workspace_and_language();// 11 + test_integration_affects_workspace_list_tool(); // 12 + + std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index 608a986..83f3a3b 100644 --- a/progress.md +++ b/progress.md @@ -12287,3 +12287,39 @@ explicit `--workspace` flag by finding the nearest `.whetstone.json` from CWD. - `editor/src/mcp_main.cpp` within main-size limit (`184` <= `1500`) - `editor/tests/step620_test.cpp` within test-file size guidance (`191` lines) - Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md` + +### Step 621: `whetstone_set_workspace` MCP tool +**Status:** PASS (12/12 tests) + +Adds runtime workspace/language switching for a live MCP daemon without +restart, including a new headless RPC method to apply workspace context. + +**Files modified:** +- `editor/src/mcp/RegisterArchitectIntakeTools.h` - added: + - `whetstone_set_workspace` tool registration, validation, and RPC bridge + - forwarding to `setWorkspaceContext` with structured response mapping +- `editor/src/headless_rpc/DispatchPart3.h` - added `setWorkspaceContext`: + - applies workspace root + default language + - re-indexes workspace and returns file/dir counts +- `editor/src/AgentPermissionPolicy.h` - permission rule for `setWorkspaceContext` +- `editor/CMakeLists.txt` - `step621_test` target + +**Files added:** +- `editor/tests/step621_test.cpp` - 12 tests covering: + - tool registration/schema and input validation + - RPC forwarding method/argument correctness + - RPC success/error propagation + - integration with HeadlessEditorState workspace switching + workspace listing + +**Verification run:** +- `cmake -S editor -B editor/build-native` - PASS +- `cmake --build editor/build-native --target step621_test step620_test` - PASS +- `./editor/build-native/step621_test` - PASS (12/12) +- `./editor/build-native/step620_test` - PASS (12/12) regression coverage + +**Architecture gate check:** +- `editor/src/mcp/RegisterArchitectIntakeTools.h` within header-size limit (`552` <= `600`) +- `editor/src/headless_rpc/DispatchPart3.h` within header-size limit (`494` <= `600`) +- `editor/src/AgentPermissionPolicy.h` within header-size limit (`132` <= `600`) +- `editor/tests/step621_test.cpp` within test-file size guidance (`229` lines) +- Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md`