diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 5807b7c..e1d0015 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1489,4 +1489,14 @@ target_link_libraries(step255_test PRIVATE tree_sitter_java tree_sitter_rust tree_sitter_go tree_sitter_org) +add_executable(step256_test tests/step256_test.cpp) +target_include_directories(step256_test PRIVATE src) +target_link_libraries(step256_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 + tree_sitter_org) + # 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 5ad23da..94cef43 100644 --- a/editor/src/AgentPermissionPolicy.h +++ b/editor/src/AgentPermissionPolicy.h @@ -49,7 +49,8 @@ struct AgentPermissionPolicy { method == "getASTDiff" || method == "getDiagnostics" || method == "getDiagnosticsDelta" || - method == "getQuickFixes") { + method == "getQuickFixes" || + method == "batchQuery") { return true; } diff --git a/editor/src/HeadlessAgentRPCHandler.h b/editor/src/HeadlessAgentRPCHandler.h index 8d3eb6e..3d5a6bd 100644 --- a/editor/src/HeadlessAgentRPCHandler.h +++ b/editor/src/HeadlessAgentRPCHandler.h @@ -932,5 +932,35 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, return headlessRpcResult(id, {{"files", files}}); } + // --- batchQuery --- + if (method == "batchQuery") { + auto params = request.contains("params") ? request["params"] + : json::object(); + if (!params.contains("queries") || !params["queries"].is_array()) + return headlessRpcError(id, -32602, "Missing queries array"); + json results = json::array(); + for (const auto& q : params["queries"]) { + std::string subMethod = q.value("method", ""); + json subParams = q.contains("params") ? q["params"] + : json::object(); + json subReq = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", subMethod}, {"params", subParams}}; + json subResp = handleHeadlessAgentRequest( + state, subReq, sessionId); + // Extract result or error from the sub-response + if (subResp.contains("result")) + results.push_back({{"method", subMethod}, + {"result", subResp["result"]}}); + else if (subResp.contains("error")) + results.push_back({{"method", subMethod}, + {"error", subResp["error"]}}); + else + results.push_back({{"method", subMethod}, + {"error", "Unknown response"}}); + } + return headlessRpcResult(id, + {{"results", results}, {"count", (int)results.size()}}); + } + return headlessRpcError(id, -32601, "Method not found"); } diff --git a/editor/src/MCPServer.h b/editor/src/MCPServer.h index c9e82bb..2620fea 100644 --- a/editor/src/MCPServer.h +++ b/editor/src/MCPServer.h @@ -721,10 +721,39 @@ private: // --------------------------------------------------------------- // Register all tools // --------------------------------------------------------------- + // --------------------------------------------------------------- + // Batch query tool + // --------------------------------------------------------------- + void registerBatchTools() { + // whetstone_batch_query + tools_.push_back({"whetstone_batch_query", + "Execute multiple queries in a single round-trip. " + "Pass an array of {method, params} objects. Each sub-query " + "runs independently; errors in one don't affect others.", + {{"type", "object"}, + {"properties", { + {"queries", {{"type", "array"}, + {"items", {{"type", "object"}, + {"properties", { + {"method", {{"type", "string"}}}, + {"params", {{"type", "object"}}} + }}, + {"required", json::array({"method"})} + }} + }} + }}, + {"required", json::array({"queries"})}} + }); + toolHandlers_["whetstone_batch_query"] = [this](const json& args) { + return callWhetstone("batchQuery", args); + }; + } + void registerWhetstoneTools() { registerASTTools(); registerAnnotationTools(); registerFileTools(); registerDiagnosticTools(); + registerBatchTools(); } }; diff --git a/editor/tests/step256_test.cpp b/editor/tests/step256_test.cpp new file mode 100644 index 0000000..25a3e10 --- /dev/null +++ b/editor/tests/step256_test.cpp @@ -0,0 +1,358 @@ +// Step 256 TDD Test: Batch Query Endpoint +// +// Tests batchQuery method: multiple queries in a single round-trip, +// independent error handling, and MCP tool registration. +#include "HeadlessEditorState.h" +#include "CompactAST.h" +#include "MCPBridge.h" + +#include +#include + +static void expect(bool cond, const std::string& name, + int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) + << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) + << " FAIL: " << name << "\n"; + ++failed; + } +} + +static json rpc(HeadlessEditorState& state, const std::string& session, + const std::string& method, + json params = json::object()) { + json request = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", method}, {"params", params}}; + return state.processAgentRequest(request, session); +} + +int main() { + int passed = 0; + int failed = 0; + + std::string src = + "def helper(x):\n" + " return x * 2\n\n" + "def compute(a, b):\n" + " c = helper(a)\n" + " d = helper(b)\n" + " return c + d\n\n" + "def main():\n" + " result = compute(1, 2)\n" + " return result\n"; + + // --------------------------------------------------------------- + // Test 1: Basic batch with 3 queries returns 3 results + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("batch.py", src, "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + json resp = rpc(state, "s1", "batchQuery", { + {"queries", json::array({ + {{"method", "getAST"}, {"params", {{"compact", true}}}}, + {{"method", "getDiagnostics"}}, + {{"method", "ping"}} + })} + }); + bool hasResults = resp.contains("result") && + resp["result"].contains("results") && + resp["result"]["results"].is_array(); + int count = resp["result"].value("count", 0); + expect(hasResults && count == 3, + "Batch of 3 queries returns 3 results", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 2: Each result has method field + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("batch.py", src, "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + json resp = rpc(state, "s1", "batchQuery", { + {"queries", json::array({ + {{"method", "getAST"}}, + {{"method", "ping"}} + })} + }); + bool allHaveMethod = true; + for (const auto& r : resp["result"]["results"]) { + if (!r.contains("method")) allHaveMethod = false; + } + expect(allHaveMethod, + "Each batch result has method field", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 3: Successful sub-queries have result field + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("batch.py", src, "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + json resp = rpc(state, "s1", "batchQuery", { + {"queries", json::array({ + {{"method", "ping"}}, + {{"method", "getAST"}, {"params", {{"compact", true}}}} + })} + }); + bool pingOk = resp["result"]["results"][0].contains("result"); + bool astOk = resp["result"]["results"][1].contains("result"); + expect(pingOk && astOk, + "Successful sub-queries have result field", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 4: Error in one sub-query doesn't fail others + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("batch.py", src, "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + json resp = rpc(state, "s1", "batchQuery", { + {"queries", json::array({ + {{"method", "ping"}}, + {{"method", "nonExistentMethod"}}, + {{"method", "getAST"}, {"params", {{"compact", true}}}} + })} + }); + bool pingOk = resp["result"]["results"][0].contains("result"); + bool errorOk = resp["result"]["results"][1].contains("error"); + bool astOk = resp["result"]["results"][2].contains("result"); + expect(pingOk && errorOk && astOk, + "Error in sub-query #2 doesn't affect #1 or #3", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 5: Batch with AST + diagnostics + scope + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("batch.py", src, "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + std::string computeId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "compute") { + computeId = c->id; + break; + } + } + + json resp = rpc(state, "s1", "batchQuery", { + {"queries", json::array({ + {{"method", "getAST"}, {"params", {{"compact", true}}}}, + {{"method", "getDiagnostics"}}, + {{"method", "getInScopeSymbols"}, + {"params", {{"nodeId", computeId}}}} + })} + }); + auto& results = resp["result"]["results"]; + bool astHasNodes = results[0]["result"].contains("nodes") || + results[0]["result"].contains("ast"); + bool diagHasDiags = results[1]["result"].contains("diagnostics"); + bool scopeHasSymbols = results[2]["result"].contains("symbols"); + expect(astHasNodes && diagHasDiags && scopeHasSymbols, + "Batch [getAST, getDiagnostics, getInScopeSymbols] all succeed", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 6: Empty batch returns empty results + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("batch.py", src, "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + json resp = rpc(state, "s1", "batchQuery", { + {"queries", json::array()} + }); + int count = resp["result"].value("count", -1); + expect(count == 0, + "Empty batch returns count=0", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 7: Missing queries parameter returns error + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("batch.py", src, "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + json resp = rpc(state, "s1", "batchQuery", json::object()); + bool hasError = resp.contains("error"); + expect(hasError, + "Missing queries parameter returns error", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 8: Batch with budget params passes through + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("batch.py", src, "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + json resp = rpc(state, "s1", "batchQuery", { + {"queries", json::array({ + {{"method", "getAST"}, + {"params", {{"compact", true}, {"budget", 200}}}} + })} + }); + auto& r = resp["result"]["results"][0]["result"]; + bool hasTruncated = r.contains("truncated"); + expect(hasTruncated, + "Budget parameter passes through in batch sub-query", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 9: Batch uses single round-trip for multiple queries + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("batch.py", src, "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + // Batch call returns all 3 results in a single response + json batchResp = rpc(state, "s1", "batchQuery", { + {"queries", json::array({ + {{"method", "getAST"}, {"params", {{"compact", true}}}}, + {{"method", "getDiagnostics"}}, + {{"method", "ping"}} + })} + }); + int batchCount = batchResp["result"].value("count", 0); + bool allHaveResults = true; + for (const auto& r : batchResp["result"]["results"]) { + if (!r.contains("result") && !r.contains("error")) + allHaveResults = false; + } + // One envelope, three results — that's the protocol win + expect(batchCount == 3 && allHaveResults, + "Single round-trip returns 3 results in one envelope", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 10: Permission denied in sub-query returns error + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("batch.py", src, "python"); + state.setAgentRole("s1", AgentRole::Linter); + + json resp = rpc(state, "s1", "batchQuery", { + {"queries", json::array({ + {{"method", "ping"}}, + {{"method", "applyMutation"}, + {"params", {{"type", "deleteNode"}, + {"nodeId", "bad"}}}} + })} + }); + bool pingOk = resp["result"]["results"][0].contains("result"); + bool mutErr = resp["result"]["results"][1].contains("error"); + expect(pingOk && mutErr, + "Permission-denied sub-query returns error, others succeed", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 11: MCP tool registration + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("batch.py", src, "python"); + state.setAgentRole("mcp-session", AgentRole::Refactor); + + MCPBridge bridge; + bridge.setRequestHandler([&state](const json& request) -> json { + return state.processAgentRequest(request, "mcp-session"); + }); + + json initReq = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "initialize"}, + {"params", {{"protocolVersion", "2024-11-05"}, + {"clientInfo", {{"name", "test"}}}}}}; + bridge.processMessage(initReq.dump()); + + json toolsReq = {{"jsonrpc", "2.0"}, {"id", 2}, + {"method", "tools/list"}}; + std::string toolsResp = bridge.processMessage(toolsReq.dump()); + json toolsJson = json::parse(toolsResp); + bool found = false; + for (const auto& t : toolsJson["result"]["tools"]) { + if (t["name"] == "whetstone_batch_query") found = true; + } + expect(found, + "whetstone_batch_query registered in MCP tools/list", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 12: Batch with detailed and lean scope queries + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("batch.py", src, "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + std::string computeId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "compute") { + computeId = c->id; + break; + } + } + + json resp = rpc(state, "s1", "batchQuery", { + {"queries", json::array({ + {{"method", "getInScopeSymbols"}, + {"params", {{"nodeId", computeId}}}}, + {{"method", "getInScopeSymbols"}, + {"params", {{"nodeId", computeId}, {"detailed", true}}}} + })} + }); + auto& results = resp["result"]["results"]; + std::string leanMode = results[0]["result"].value("mode", ""); + std::string detMode = results[1]["result"].value("mode", ""); + expect(leanMode == "symbols" && detMode == "detailed", + "Batch lean + detailed scope queries return correct modes", + passed, failed); + } + + std::cout << "\n=== Step 256 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index 61f991b..0986f1c 100644 --- a/progress.md +++ b/progress.md @@ -339,3 +339,33 @@ getDependencyGraph. Default (lean) mode returns minimal symbol data - Lean scope responses ~7% the size of detailed (302 vs 4329 chars) - getDependencyGraph lean mode converts vector IDs to JSON array - Uses getNodeName() from CompactAST.h for cross-type name extraction + +### Step 256: Batch Query Endpoint +**Status:** PASS (12/12 tests) + +`batchQuery` method accepts an array of sub-queries, executes each +independently, and returns all results in a single round-trip. Errors +in one sub-query don't affect others. + +**Files created:** +- `editor/tests/step256_test.cpp` — 12 test cases: 3-query batch, method fields, + result fields, error isolation, AST+diagnostics+scope batch, empty batch, + missing params error, budget passthrough, single-envelope multi-result, + permission-denied isolation, MCP registration, lean+detailed mixed batch + +**Files modified:** +- `editor/src/HeadlessAgentRPCHandler.h` — batchQuery RPC method: iterates + sub-queries, calls handleHeadlessAgentRequest recursively, collects + results/errors independently +- `editor/src/AgentPermissionPolicy.h` — batchQuery allowed for all roles + (sub-queries enforce their own permissions) +- `editor/src/MCPServer.h` — registerBatchTools() with whetstone_batch_query + tool definition and callWhetstone handler +- `editor/CMakeLists.txt` — step256_test target + +**Key design decisions:** +- Sub-queries reuse the same dispatch function (recursive call) +- Each sub-query result tagged with its method name for correlation +- Error isolation: failed sub-query returns error object, others unaffected +- batchQuery itself requires no special permission; sub-queries enforce their own +- tools/list now returns 22 tools (was 21): +whetstone_batch_query