From 9fcb5a0c8c9e0e8a5cdc4bfe9469aa8397ee7b33 Mon Sep 17 00:00:00 2001 From: Bill Date: Wed, 11 Feb 2026 01:34:27 +0000 Subject: [PATCH] =?UTF-8?q?Step=20245:=20HeadlessEditorState=20=E2=80=94?= =?UTF-8?q?=20agent=20API=20surface=20without=20ImGui/SDL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the headless agent architecture for Sprint 9 Phase 9a: - ASTUtils.h: pure AST utilities extracted from EditorUtils.h - HeadlessEditorState.h: GUI-free state with buffer management - HeadlessAgentRPCHandler.h: full RPC dispatch (20+ methods) - step245_test.cpp: 20 tests all passing Also fixes test compilation errors (NotificationSystem API changes, AgentRole permissions, DependencyPanel missing include) and adds SDL2 system library detection fix in CMakeLists.txt. Co-Authored-By: Claude Opus 4.6 --- editor/CMakeLists.txt | 43 +- editor/src/ASTUtils.h | 77 +++ editor/src/DependencyPanel.h | 1 + editor/src/HeadlessAgentRPCHandler.h | 558 ++++++++++++++++++++++ editor/src/HeadlessEditorState.h | 239 +++++++++ editor/tests/step125_integration_test.cpp | 5 +- editor/tests/step129_test.cpp | 18 +- editor/tests/step142_test.cpp | 7 +- editor/tests/step143_test.cpp | 11 +- editor/tests/step144_test.cpp | 13 +- editor/tests/step245_test.cpp | 126 +++++ progress.md | 25 + sprint9_plan.md | 427 +++++++++++++++++ 13 files changed, 1522 insertions(+), 28 deletions(-) create mode 100644 editor/src/ASTUtils.h create mode 100644 editor/src/HeadlessAgentRPCHandler.h create mode 100644 editor/src/HeadlessEditorState.h create mode 100644 editor/tests/step245_test.cpp create mode 100644 progress.md create mode 100644 sprint9_plan.md diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index a6cbf6b..ba837f7 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -764,7 +764,13 @@ target_link_libraries(step125_integration_test PRIVATE unofficial::tree-sitter::tree-sitter tree_sitter_python tree_sitter_cpp - tree_sitter_elisp) + tree_sitter_elisp + tree_sitter_javascript + tree_sitter_rust + tree_sitter_go + tree_sitter_typescript + tree_sitter_java + tree_sitter_org) add_executable(step126_test tests/step126_test.cpp) target_include_directories(step126_test PRIVATE src) @@ -780,7 +786,7 @@ target_link_libraries(step128_test PRIVATE nlohmann_json::nlohmann_json) add_executable(step129_test tests/step129_test.cpp) target_include_directories(step129_test PRIVATE src) -target_link_libraries(step129_test PRIVATE nlohmann_json::nlohmann_json) +target_link_libraries(step129_test PRIVATE nlohmann_json::nlohmann_json imgui::imgui) add_executable(step130_test tests/step130_test.cpp) target_include_directories(step130_test PRIVATE src) @@ -1249,7 +1255,20 @@ target_link_libraries(step206_test PRIVATE tree_sitter_rust tree_sitter_go) -find_package(SDL2 CONFIG REQUIRED) +if(UNIX AND NOT APPLE) + # Force system SDL2 on Linux. The vcpkg toolchain overrides PKG_CONFIG_PATH, + # causing pkg_check_modules to find vcpkg's headless (offscreen-only) SDL2. + # We bypass that by pointing pkg-config directly at the system .pc files. + find_package(PkgConfig REQUIRED) + set(_saved_pkg_config_path "$ENV{PKG_CONFIG_PATH}") + set(ENV{PKG_CONFIG_PATH} "/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig") + pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2) + set(ENV{PKG_CONFIG_PATH} "${_saved_pkg_config_path}") + add_library(SDL2::SDL2 INTERFACE IMPORTED) + target_link_libraries(SDL2::SDL2 INTERFACE PkgConfig::SDL2) +else() + find_package(SDL2 CONFIG REQUIRED) +endif() find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) find_package(imgui CONFIG REQUIRED) @@ -1296,7 +1315,7 @@ if(IMGUI_SDL2_BACKEND) endif() target_link_libraries(whetstone_editor PRIVATE SDL2::SDL2 - SDL2::SDL2main + $ OpenGL::GL glad::glad imgui::imgui @@ -1335,4 +1354,20 @@ add_executable(whetstone_pipeline src/pipeline_main.cpp) target_include_directories(whetstone_pipeline PRIVATE src) target_link_libraries(whetstone_pipeline PRIVATE nlohmann_json::nlohmann_json) +# Step 245: Headless EditorState test (no SDL/ImGui dependency) +add_executable(step245_test tests/step245_test.cpp) +target_include_directories(step245_test PRIVATE src) +target_link_libraries(step245_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/ASTUtils.h b/editor/src/ASTUtils.h new file mode 100644 index 0000000..0e7798c --- /dev/null +++ b/editor/src/ASTUtils.h @@ -0,0 +1,77 @@ +#pragma once +// ASTUtils.h — Pure AST utility functions (no ImGui/SDL dependency). +// Extracted for use by both EditorUtils.h and HeadlessEditorState.h. + +#include "ast/ASTNode.h" +#include "ast/Serialization.h" +#include +#include + +// Clone a Module tree (deep copy via JSON roundtrip) +static inline std::unique_ptr cloneModule(const Module* ast) { + if (!ast) return nullptr; + json j = toJson(ast); + ASTNode* node = fromJson(j); + if (!node || node->conceptType != "Module") return nullptr; + return std::unique_ptr(static_cast(node)); +} + +// Check if a node is an annotation type +static inline bool isAnnotationNode(const ASTNode* node) { + if (!node) return false; + if (node->conceptType.find("Annotation") != std::string::npos) return true; + if (node->conceptType == "DerefStrategy") return true; + if (node->conceptType == "OptimizationLock") return true; + if (node->conceptType == "LangSpecific") return true; + return false; +} + +// Count annotation nodes in an AST subtree +static inline int countAnnotationNodes(const ASTNode* node) { + if (!node) return 0; + int count = isAnnotationNode(node) ? 1 : 0; + for (const auto* child : node->allChildren()) + count += countAnnotationNodes(child); + return count; +} + +// Find a node by ID in an AST tree +static inline ASTNode* findNodeById(ASTNode* node, + const std::string& id) { + if (!node) return nullptr; + if (node->id == id) return node; + for (auto* child : node->allChildren()) + if (auto* found = findNodeById(child, id)) return found; + return nullptr; +} + +// Check if a span contains a position +static inline bool spanContains(const ASTNode* node, int line, int col) { + if (!node || !node->hasSpan()) return false; + if (line < node->spanStartLine || line > node->spanEndLine) return false; + if (line == node->spanStartLine && col < node->spanStartCol) return false; + if (line == node->spanEndLine && col > node->spanEndCol) return false; + return true; +} + +// Span score (smaller span = better match) +static inline int spanScore(const ASTNode* node) { + if (!node || !node->hasSpan()) return 999999; + return (node->spanEndLine - node->spanStartLine) * 1000 + + (node->spanEndCol - node->spanStartCol); +} + +// Find a node at a line/col position +static inline ASTNode* findNodeAtPosition(ASTNode* node, + int line, int col) { + if (!node) return nullptr; + ASTNode* best = nullptr; + if (spanContains(node, line, col)) best = node; + for (auto* child : node->allChildren()) { + ASTNode* cand = findNodeAtPosition(child, line, col); + if (cand) { + if (!best || spanScore(cand) < spanScore(best)) best = cand; + } + } + return best; +} diff --git a/editor/src/DependencyPanel.h b/editor/src/DependencyPanel.h index 15b6264..69bf922 100644 --- a/editor/src/DependencyPanel.h +++ b/editor/src/DependencyPanel.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include diff --git a/editor/src/HeadlessAgentRPCHandler.h b/editor/src/HeadlessAgentRPCHandler.h new file mode 100644 index 0000000..833e7a3 --- /dev/null +++ b/editor/src/HeadlessAgentRPCHandler.h @@ -0,0 +1,558 @@ +#pragma once +// Step 245: Headless Agent RPC Handler +// +// Mirrors AgentRPCHandler.h but operates on HeadlessEditorState. +// Uses the same agentRpcError/agentRpcResult helpers and supports +// the same JSON-RPC methods for the agent-critical path: +// getAST, parseSource, generateFromAST, runPipeline, projectLanguage, +// applyMutation, applyBatch, getInScopeSymbols, getCallHierarchy, +// getDependencyGraph, generateCode, setAgentRole, +// getAnnotationSuggestions, applyAnnotationSuggestion, +// recordAnnotationFeedback, startWorkflowRecording, +// stopWorkflowRecording, getWorkflowRecording, replayWorkflow, ping +// +// Deliberately omits GUI-only methods. Stays under 600 lines. + +struct HeadlessEditorState; + +// Re-use the response helpers (same signature as AgentRPCHandler.h) +static inline json headlessRpcError(const json& id, int code, + const std::string& msg) { + return {{"jsonrpc", "2.0"}, {"id", id}, + {"error", {{"code", code}, {"message", msg}}}}; +} + +static inline json headlessRpcResult(const json& id, const json& result) { + return {{"jsonrpc", "2.0"}, {"id", id}, {"result", result}}; +} + +static inline json headlessRequireAST(HeadlessEditorState& state, + const json& id) { + if (!state.active() || !state.isStructured()) + return headlessRpcError(id, -32000, "No structured buffer"); + if (!state.activeAST()) + return headlessRpcError(id, -32001, "AST unavailable"); + return json(); +} + +static inline json headlessRequireMutable(HeadlessEditorState& state, + const json& id) { + if (!state.active() || !state.isStructured()) + return headlessRpcError(id, -32000, "No structured buffer"); + if (!state.mutationAST()) + return headlessRpcError(id, -32001, "AST unavailable"); + return json(); +} + +// ----------------------------------------------------------------------- +// Main dispatch +// ----------------------------------------------------------------------- +inline json handleHeadlessAgentRequest(HeadlessEditorState& state, + const json& request, + const std::string& sessionId) { + json id = request.contains("id") ? request["id"] : json(nullptr); + std::string method = request.value("method", ""); + AgentRole role = state.getAgentRole(sessionId); + + // --- ping --- + if (method == "ping") + return headlessRpcResult(id, "pong"); + + // --- getAST --- + if (method == "getAST") { + auto err = headlessRequireAST(state, id); + if (!err.is_null()) return err; + return headlessRpcResult(id, { + {"ast", toJson(state.activeAST())}, + {"annotationCount", countAnnotationNodes(state.activeAST())}, + {"diagnostics", state.buildDiagnosticsJson()} + }); + } + + // --- parseSource --- + if (method == "parseSource") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string source = params.value("source", ""); + std::string language = params.value("language", ""); + if (source.empty() || language.empty()) + return headlessRpcError(id, -32602, + "Missing source or language"); + Pipeline pipeline; + std::vector diags; + auto mod = pipeline.parse(source, language, diags); + json diagArr = json::array(); + for (const auto& d : diags) + diagArr.push_back({{"line", d.line}, {"column", d.column}, + {"message", d.message}, + {"severity", d.severity}}); + json result = {{"diagnostics", diagArr}}; + if (mod) result["ast"] = toJson(mod.get()); + return headlessRpcResult(id, result); + } + + // --- generateFromAST --- + if (method == "generateFromAST") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto err = headlessRequireAST(state, id); + if (!err.is_null()) return err; + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string language = params.value("language", + state.active()->language); + Pipeline pipeline; + std::string code = pipeline.generate(state.activeAST(), language); + return headlessRpcResult(id, {{"code", code}, + {"language", language}}); + } + + // --- runPipeline --- + if (method == "runPipeline") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string source = params.value("source", ""); + std::string srcLang = params.value("sourceLanguage", ""); + std::string tgtLang = params.value("targetLanguage", ""); + if (source.empty() || srcLang.empty() || tgtLang.empty()) + return headlessRpcError(id, -32602, + "Missing source, sourceLanguage, or targetLanguage"); + Pipeline pipeline; + auto pr = pipeline.run(source, srcLang, tgtLang); + json diagArr = json::array(); + for (const auto& d : pr.parseDiags) + diagArr.push_back({{"line", d.line}, {"column", d.column}, + {"message", d.message}, + {"severity", d.severity}}); + json valArr = json::array(); + for (const auto& d : pr.validationDiags) + valArr.push_back({{"severity", d.severity}, + {"message", d.message}, + {"nodeId", d.nodeId}}); + json violArr = json::array(); + for (const auto& v : pr.violations) + violArr.push_back({{"severity", v.severity}, + {"category", v.category}, + {"message", v.message}, + {"nodeId", v.nodeId}}); + json suggArr = json::array(); + for (const auto& s : pr.suggestions) + suggArr.push_back({{"nodeId", s.nodeId}, + {"annotationType", s.annotationType}, + {"strategy", s.strategy}, + {"reason", s.reason}, + {"confidence", s.confidence}}); + json result = { + {"success", pr.success}, {"generatedCode", pr.generatedCode}, + {"parseDiagnostics", diagArr}, + {"validationDiagnostics", valArr}, + {"violations", violArr}, {"suggestions", suggArr}, + {"foldCount", pr.foldResult.nodesModified}, + {"dceCount", pr.dceResult.nodesModified} + }; + if (pr.ast) result["ast"] = toJson(pr.ast.get()); + return headlessRpcResult(id, result); + } + + // --- projectLanguage --- + if (method == "projectLanguage") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto err = headlessRequireAST(state, id); + if (!err.is_null()) return err; + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string targetLanguage = params.value("targetLanguage", ""); + if (targetLanguage.empty()) + return headlessRpcError(id, -32602, "Missing targetLanguage"); + CrossLanguageProjector projector; + auto projected = projector.project(state.activeAST(), + targetLanguage); + if (!projected) + return headlessRpcError(id, -32020, "Projection failed"); + Pipeline pipeline; + std::string code = pipeline.generate(projected.get(), + targetLanguage); + return headlessRpcResult(id, { + {"ast", toJson(projected.get())}, {"generatedCode", code}, + {"sourceLanguage", state.active()->language}, + {"targetLanguage", targetLanguage} + }); + } + + // --- setAgentRole --- + if (method == "setAgentRole") { + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string roleName = params.value("role", "linter"); + AgentRole newRole = + AgentPermissionPolicy::roleFromString(roleName); + state.setAgentRole(sessionId, newRole); + return headlessRpcResult(id, + {{"role", AgentPermissionPolicy::roleLabel(newRole)}}); + } + + // --- generateCode --- + if (method == "generateCode") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto err = headlessRequireAST(state, id); + if (!err.is_null()) return err; + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string spec = params.value("spec", ""); + bool preferImports = params.value("preferImports", true); + state.library.primitives.setRoot(state.activeAST()); + state.library.primitives.setLanguage(state.active()->language); + AgentCodeGen gen; + state.library.primitives.setContextTags( + state.library.semanticTags.inferTagsFromText(spec)); + AgentCodeGenResult genRes = gen.generate( + spec, state.library.primitives, + state.active()->language, preferImports); + if (!genRes.node) + return headlessRpcError(id, -32020, "Code generation failed"); + json nodeJson = toJson(genRes.node); + deleteTree(genRes.node); + return headlessRpcResult(id, { + {"node", nodeJson}, {"note", genRes.note}, + {"usedSymbols", genRes.usedSymbols}, + {"language", state.active()->language} + }); + } + + // --- applyMutation --- + if (method == "applyMutation") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + if (!state.active() || !state.isStructured()) + return headlessRpcError(id, -32000, "No structured buffer"); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string type = params.value("type", ""); + bool preferImports = params.value("preferImports", false); + bool strictMode = params.value("strictMode", false); + Module* ast = state.mutationAST(); + if (!ast) return headlessRpcError(id, -32001, "AST unavailable"); + ASTMutationAPI mut; + mut.setRoot(ast); + ASTMutationAPI::MutationResult res; + LibraryPolicyResult policy; + std::vector affectedIds; + if (type == "setProperty") { + std::string nodeId = params.value("nodeId", ""); + if (!nodeId.empty()) affectedIds.push_back(nodeId); + res = mut.setProperty(params.value("nodeId", ""), + params.value("property", ""), + params.value("value", "")); + } else if (type == "updateNode") { + std::map props; + if (params.contains("properties")) { + for (auto& [k, v] : params["properties"].items()) + props[k] = v.get(); + } + std::string nodeId = params.value("nodeId", ""); + if (!nodeId.empty()) affectedIds.push_back(nodeId); + res = mut.updateNode(params.value("nodeId", ""), props); + } else if (type == "deleteNode") { + std::string nodeId = params.value("nodeId", ""); + if (!nodeId.empty()) affectedIds.push_back(nodeId); + res = mut.deleteNode(params.value("nodeId", "")); + } else if (type == "insertNode") { + ASTNode* node = nullptr; + std::string insertedId; + if (params.contains("node")) { + node = fromJson(params["node"]); + if (node) insertedId = node->id; + } + if (node) { + policy = checkMutationLibraryPolicy( + node, ast, preferImports, strictMode); + if (!policy.ok) { + deleteTree(node); + return headlessRpcError(id, -32011, policy.error); + } + } + res = mut.insertNode(params.value("parentId", ""), + params.value("role", ""), node); + if (!res.success && node) { + deleteTree(node); + } else if (res.success && !insertedId.empty()) { + affectedIds.push_back(insertedId); + } + } else { + return headlessRpcError(id, -32602, "Unknown mutation type"); + } + if (!res.success) + return headlessRpcError(id, -32010, res.error); + state.applyOrchestratorToActive(); + if (state.active()) { + state.active()->incrementalOptimizer.setRoot( + state.active()->sync.getAST()); + state.active()->incrementalOptimizer.recordExternalTransform( + "agent-mutation:" + type, affectedIds, + state.agentActorLabel(sessionId)); + } + return headlessRpcResult(id, { + {"success", true}, {"warning", res.warning}, + {"libraryWarning", policy.warning}, + {"unknownFunctions", policy.unknownFunctions} + }); + } + + // --- applyBatch --- + if (method == "applyBatch") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto errChk = headlessRequireMutable(state, id); + if (!errChk.is_null()) return errChk; + Module* ast = state.mutationAST(); + auto params = request.contains("params") ? request["params"] + : json::object(); + if (!params.contains("mutations") || + !params["mutations"].is_array()) + return headlessRpcError(id, -32602, + "Missing mutations array"); + BatchMutationAPI batch; + batch.setRoot(ast); + std::vector mutations; + std::vector ownedNodes; + for (const auto& m : params["mutations"]) { + BatchMutationAPI::Mutation mut; + mut.type = m.value("type", ""); + mut.nodeId = m.value("nodeId", ""); + mut.property = m.value("property", ""); + mut.value = m.value("value", ""); + mut.parentId = m.value("parentId", ""); + mut.role = m.value("role", ""); + if (m.contains("node")) { + mut.newNode = fromJson(m["node"]); + if (mut.newNode) ownedNodes.push_back(mut.newNode); + } + mutations.push_back(mut); + } + auto batchRes = batch.applySequence(mutations); + if (!batchRes.success) { + for (auto* n : ownedNodes) { + if (n->parent == nullptr) deleteTree(n); + } + return headlessRpcError(id, -32010, batchRes.error); + } + state.applyOrchestratorToActive(); + if (state.active()) { + state.active()->incrementalOptimizer.setRoot( + state.active()->sync.getAST()); + state.active()->incrementalOptimizer.recordExternalTransform( + "agent-batch", {}, + state.agentActorLabel(sessionId)); + } + return headlessRpcResult(id, + {{"success", true}, {"appliedCount", batchRes.appliedCount}}); + } + + // --- getInScopeSymbols --- + if (method == "getInScopeSymbols") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto err = headlessRequireAST(state, id); + if (!err.is_null()) return err; + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string nodeId = params.value("nodeId", ""); + if (nodeId.empty()) + return headlessRpcError(id, -32602, + "Missing nodeId parameter"); + ContextAPI ctx; + ctx.setRoot(state.activeAST()); + auto symbols = ctx.getInScopeSymbols(nodeId); + json arr = json::array(); + for (const auto& s : symbols) + arr.push_back({{"name", s.name}, {"kind", s.kind}, + {"nodeId", s.nodeId}}); + return headlessRpcResult(id, {{"symbols", arr}}); + } + + // --- getCallHierarchy --- + if (method == "getCallHierarchy") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto err = headlessRequireAST(state, id); + if (!err.is_null()) return err; + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string functionId = params.value("functionId", ""); + if (functionId.empty()) + return headlessRpcError(id, -32602, + "Missing functionId parameter"); + ContextAPI ctx; + ctx.setRoot(state.activeAST()); + auto info = ctx.getCallHierarchy(functionId); + return headlessRpcResult(id, { + {"functionId", info.functionId}, + {"functionName", info.functionName}, + {"callerIds", info.callerIds}, + {"calleeIds", info.calleeIds} + }); + } + + // --- getDependencyGraph --- + if (method == "getDependencyGraph") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto err = headlessRequireAST(state, id); + if (!err.is_null()) return err; + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string nodeId = params.value("nodeId", ""); + if (nodeId.empty()) + return headlessRpcError(id, -32602, + "Missing nodeId parameter"); + ContextAPI ctx; + ctx.setRoot(state.activeAST()); + auto deps = ctx.getDependencyGraph(nodeId); + return headlessRpcResult(id, {{"dependencies", deps}}); + } + + // --- getAnnotationSuggestions --- + if (method == "getAnnotationSuggestions") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto err = headlessRequireAST(state, id); + if (!err.is_null()) return err; + Module* ast = state.activeAST(); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string nodeId = params.value("nodeId", ""); + if (!nodeId.empty()) { + if (!findNodeById(ast, nodeId)) + return headlessRpcError(id, -32002, + "Node not found: " + nodeId); + } + AgentAnnotationAssistant assistant; + auto result = assistant.suggest(ast, nodeId); + json suggArr = json::array(); + for (const auto& s : result.suggestions) + suggArr.push_back({ + {"nodeId", s.nodeId}, + {"annotationType", s.annotationType}, + {"strategy", s.strategy}, {"reason", s.reason}, + {"confidence", s.confidence} + }); + json diagArr = json::array(); + for (const auto& d : result.diagnostics) + diagArr.push_back({{"severity", d.severity}, + {"message", d.message}, + {"nodeId", d.nodeId}}); + return headlessRpcResult(id, {{"scopeId", result.scopeNodeId}, + {"suggestions", suggArr}, + {"diagnostics", diagArr}}); + } + + // --- applyAnnotationSuggestion --- + if (method == "applyAnnotationSuggestion") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto err = headlessRequireMutable(state, id); + if (!err.is_null()) return err; + Module* ast = state.mutationAST(); + auto params = request.contains("params") ? request["params"] + : json::object(); + MemoryStrategyInference::Suggestion suggestion; + suggestion.nodeId = params.value("nodeId", ""); + suggestion.annotationType = params.value("annotationType", ""); + suggestion.strategy = params.value("strategy", ""); + suggestion.reason = params.value("reason", ""); + suggestion.confidence = params.value("confidence", 0.0); + AgentAnnotationAssistant assistant; + auto res = assistant.applySuggestion(ast, suggestion); + if (!res.success) + return headlessRpcError(id, -32010, res.error); + if (params.contains("accepted")) { + bool accepted = params.value("accepted", true); + assistant.recordFeedback(suggestion, accepted); + } + state.applyOrchestratorToActive(); + return headlessRpcResult(id, + {{"success", true}, {"warning", res.warning}}); + } + + // --- recordAnnotationFeedback --- + if (method == "recordAnnotationFeedback") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] + : json::object(); + MemoryStrategyInference::Suggestion suggestion; + suggestion.nodeId = params.value("nodeId", ""); + suggestion.annotationType = params.value("annotationType", ""); + suggestion.strategy = params.value("strategy", ""); + suggestion.reason = params.value("reason", ""); + suggestion.confidence = params.value("confidence", 0.0); + bool accepted = params.value("accepted", false); + AgentAnnotationAssistant assistant; + assistant.recordFeedback(suggestion, accepted); + return headlessRpcResult(id, true); + } + + // --- startWorkflowRecording --- + if (method == "startWorkflowRecording") { + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string name = params.value("name", "workflow"); + state.agent.workflowRecorder.startRecording( + name, sessionId, WorkflowRecorder::RecordingConfig{}, + state.buildSessionMetadata(false)); + return headlessRpcResult(id, + {{"recording", true}, {"name", name}}); + } + + // --- stopWorkflowRecording --- + if (method == "stopWorkflowRecording") + return headlessRpcResult(id, + state.agent.workflowRecorder.stopRecording()); + + // --- getWorkflowRecording --- + if (method == "getWorkflowRecording") + return headlessRpcResult(id, + state.agent.workflowRecorder.exportWorkflow()); + + // --- replayWorkflow --- + if (method == "replayWorkflow") { + auto params = request.contains("params") ? request["params"] + : json::object(); + if (!params.contains("workflow")) + return headlessRpcError(id, -32602, + "Missing workflow payload"); + WorkflowRecorder temp; + if (!temp.loadWorkflow(params["workflow"])) + return headlessRpcError(id, -32602, + "Invalid workflow payload"); + auto requests = temp.buildReplayRequests(); + json results = json::array(); + state.agent.workflowRecorder.setReplaying(true); + for (auto& req : requests) + results.push_back( + handleHeadlessAgentRequest(state, req, sessionId)); + state.agent.workflowRecorder.setReplaying(false); + return headlessRpcResult(id, + {{"count", results.size()}, {"responses", results}}); + } + + // --- getSessionInfo --- + if (method == "getSessionInfo") { + return headlessRpcResult(id, { + {"mode", "headless"}, + {"workspace", state.workspaceRoot}, + {"language", state.defaultLanguage}, + {"bufferCount", (int)state.bufferStates.size()}, + {"activeBuffer", state.active() ? state.active()->path : ""}, + {"role", AgentPermissionPolicy::roleLabel(role)} + }); + } + + return headlessRpcError(id, -32601, "Method not found"); +} diff --git a/editor/src/HeadlessEditorState.h b/editor/src/HeadlessEditorState.h new file mode 100644 index 0000000..4773196 --- /dev/null +++ b/editor/src/HeadlessEditorState.h @@ -0,0 +1,239 @@ +#pragma once +// Step 245: Headless EditorState — agent API surface without ImGui/SDL. +// +// Provides the same method interface that AgentRPCHandler.h expects, +// but with zero GUI dependencies. Uses std::chrono for timestamps, +// stderr for logging, and holds its own lightweight buffer map. + +// --- Core AST and pipeline (no ImGui) --- +#include "ast/ASTNode.h" +#include "ast/Serialization.h" +#include "ast/Generator.h" +#include "ast/Annotation.h" +#include "ASTUtils.h" +#include "Pipeline.h" +#include "ASTMutationAPI.h" +#include "BatchMutationAPI.h" +#include "ContextAPI.h" +#include "CrossLanguageProjector.h" +#include "MemoryStrategyInference.h" +#include "AgentAnnotationAssistant.h" +#include "AgentPermissionPolicy.h" +#include "AgentCodeGen.h" +#include "AgentLibraryPolicy.h" +#include "WorkflowRecorder.h" +#include "Orchestrator.h" +#include "IncrementalOptimizer.h" +#include "TextASTSync.h" +#include "BufferManager.h" +#include "EditorModePolicy.h" +#include "PrimitivesRegistry.h" +#include "SemanticTags.h" + +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +// ----------------------------------------------------------------------- +// Headless agent state — no WebSocket, no AgentMarketplace (ImGui dep) +// ----------------------------------------------------------------------- +struct HeadlessAgentState { + std::map roles; + AgentRole defaultRole = AgentRole::Linter; + WorkflowRecorder workflowRecorder; +}; + +// ----------------------------------------------------------------------- +// Headless library state — no ImGui panels +// ----------------------------------------------------------------------- +struct HeadlessLibraryState { + PrimitivesRegistry primitives; + SemanticTags semanticTags; +}; + +// ----------------------------------------------------------------------- +// HeadlessBufferState — lightweight buffer without ImGui widgets +// ----------------------------------------------------------------------- +struct HeadlessBufferState { + TextASTSync sync; + Orchestrator orchestrator; + IncrementalOptimizer incrementalOptimizer; + std::string language = "python"; + std::string path = "(untitled)"; + std::string editBuf; + BufferManager::BufferMode bufferMode = + BufferManager::BufferMode::Structured; + bool orchestratorDirty = true; + bool modified = false; +}; + +// ----------------------------------------------------------------------- +// HeadlessEditorState — agent-facing state, no GUI +// ----------------------------------------------------------------------- +struct HeadlessEditorState { + std::map> + bufferStates; + HeadlessBufferState* activeBuffer = nullptr; + HeadlessAgentState agent; + HeadlessLibraryState library; + std::string workspaceRoot; + std::string defaultLanguage = "python"; + bool verbose = false; + + // --- Buffer access --- + + HeadlessBufferState* active() { return activeBuffer; } + + bool isStructured() const { + return activeBuffer && + allowStructuredFeatures(activeBuffer->bufferMode); + } + + Module* activeAST() { + if (!activeBuffer) return nullptr; + if (!allowStructuredFeatures(activeBuffer->bufferMode)) + return nullptr; + return activeBuffer->sync.getAST(); + } + + Module* mutationAST() { + if (!activeBuffer) return nullptr; + if (!allowStructuredFeatures(activeBuffer->bufferMode)) + return nullptr; + if (activeBuffer->orchestratorDirty || + !activeBuffer->orchestrator.getAST()) { + syncOrchestratorFromActive(); + } + return activeBuffer->orchestrator.getAST(); + } + + void syncOrchestratorFromActive() { + if (!activeBuffer) return; + Module* ast = activeBuffer->sync.getAST(); + if (ast) { + activeBuffer->orchestrator.setAST(cloneModule(ast)); + activeBuffer->orchestratorDirty = false; + } + } + + void applyOrchestratorToActive() { + if (!activeBuffer) return; + if (!isStructured()) return; + Module* ast = activeBuffer->orchestrator.getAST(); + if (!ast) return; + activeBuffer->sync.setAST(cloneModule(ast)); + activeBuffer->incrementalOptimizer.setRoot( + activeBuffer->sync.getAST()); + Pipeline pipeline; + std::string code = pipeline.generate( + activeBuffer->sync.getAST(), activeBuffer->language); + activeBuffer->editBuf = code; + activeBuffer->orchestratorDirty = false; + } + + // --- Agent role management --- + + AgentRole getAgentRole(const std::string& sessionId) const { + auto it = agent.roles.find(sessionId); + if (it != agent.roles.end()) return it->second; + return agent.defaultRole; + } + + void setAgentRole(const std::string& sessionId, AgentRole role) { + agent.roles[sessionId] = role; + } + + std::string agentActorLabel(const std::string& sessionId) const { + AgentRole role = getAgentRole(sessionId); + return std::string(AgentPermissionPolicy::roleLabel(role)) + + ":" + sessionId; + } + + // --- Diagnostics --- + + json buildDiagnosticsJson() const { + return json::array(); + } + + json buildSessionMetadata(bool /*autoRecording*/) const { + return { + {"mode", "headless"}, + {"workspace", workspaceRoot}, + {"language", defaultLanguage}, + {"timestamp", + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count()} + }; + } + + // --- Buffer operations --- + + HeadlessBufferState* openBuffer(const std::string& filePath, + const std::string& content, + const std::string& lang) { + auto buf = std::make_unique(); + buf->path = filePath; + buf->language = lang.empty() ? defaultLanguage : lang; + buf->editBuf = content; + buf->bufferMode = BufferManager::BufferMode::Structured; + if (!content.empty()) { + Pipeline pipeline; + std::vector diags; + auto mod = pipeline.parse(content, buf->language, diags); + if (mod) { + buf->sync.setAST(std::move(mod)); + buf->orchestratorDirty = true; + } + } else { + auto mod = std::make_unique( + "root", filePath, buf->language); + buf->sync.setAST(std::move(mod)); + buf->orchestratorDirty = true; + } + HeadlessBufferState* raw = buf.get(); + bufferStates[filePath] = std::move(buf); + if (!activeBuffer) activeBuffer = raw; + return raw; + } + + bool setActiveBuffer(const std::string& path) { + auto it = bufferStates.find(path); + if (it == bufferStates.end()) return false; + activeBuffer = it->second.get(); + return true; + } + + void closeBuffer(const std::string& path) { + auto it = bufferStates.find(path); + if (it == bufferStates.end()) return; + if (activeBuffer == it->second.get()) + activeBuffer = nullptr; + bufferStates.erase(it); + if (!activeBuffer && !bufferStates.empty()) + activeBuffer = bufferStates.begin()->second.get(); + } + + // --- RPC entry point --- + json processAgentRequest(const json& request, + const std::string& sessionId); + + // --- Logging --- + void log(const std::string& msg) const { + if (verbose) + std::cerr << "[whetstone-headless] " << msg << "\n"; + } +}; + +// Include the headless RPC handler and wire up processAgentRequest +#include "HeadlessAgentRPCHandler.h" + +inline json HeadlessEditorState::processAgentRequest( + const json& request, const std::string& sessionId) { + return handleHeadlessAgentRequest(*this, request, sessionId); +} diff --git a/editor/tests/step125_integration_test.cpp b/editor/tests/step125_integration_test.cpp index 5c9c38f..7ae21c4 100644 --- a/editor/tests/step125_integration_test.cpp +++ b/editor/tests/step125_integration_test.cpp @@ -1,5 +1,6 @@ // Step 125 Integration Test: EditorState agent mutation flow #include "EditorUtils.h" +#include "AgentPermissionPolicy.h" #include "imgui.h" #include @@ -32,7 +33,7 @@ int main() { state.bufferStates[buf->path] = std::move(buf); state.activeBuffer = state.bufferStates["(untitled)"].get(); - state.agentMutationPermissions["agent_1"] = false; + state.setAgentRole("agent_1", AgentRole::Linter); // Linter cannot mutate json mutReq = { {"jsonrpc","2.0"}, {"id",1}, @@ -42,7 +43,7 @@ int main() { json mutRes = state.processAgentRequest(mutReq, "agent_1"); expect(mutRes.contains("error"), "mutation blocked", passed, failed); - state.agentMutationPermissions["agent_1"] = true; + state.setAgentRole("agent_1", AgentRole::Refactor); // Refactor can mutate json mutRes2 = state.processAgentRequest(mutReq, "agent_1"); expect(mutRes2.contains("result"), "mutation allowed", passed, failed); Module* astAfter = state.activeAST(); diff --git a/editor/tests/step129_test.cpp b/editor/tests/step129_test.cpp index 8f4a77b..86b2395 100644 --- a/editor/tests/step129_test.cpp +++ b/editor/tests/step129_test.cpp @@ -1,5 +1,7 @@ // Step 129 TDD Test: Dependency management UI writeback #include "DependencyPanel.h" +#include "NotificationSystem.h" +#include "VulnerabilityDatabase.h" #include #include #include @@ -31,6 +33,9 @@ int main() { std::filesystem::remove_all(dir); std::filesystem::create_directories(dir); + NotificationSystem notifications; + VulnerabilityDatabase vulnDb; + // requirements.txt writeback { std::ofstream out(dir / "requirements.txt"); @@ -38,8 +43,7 @@ int main() { out.close(); DependencyPanelState panel; - std::string log; - refreshDependencies(panel, dir.string(), log); + refreshDependencies(panel, dir.string(), notifications, vulnDb); expect(panel.deps.size() == 1, "requirements parsed", passed, failed); DependencySpec dep; @@ -47,7 +51,7 @@ int main() { dep.version = "2.0.0"; dep.source = "requirements.txt"; panel.deps.push_back(dep); - bool ok = writeDependenciesForSource("requirements.txt", dir.string(), panel.deps, log); + bool ok = writeDependenciesForSource("requirements.txt", dir.string(), panel.deps, notifications); expect(ok, "requirements writeback ok", passed, failed); std::string content = readFile(dir / "requirements.txt"); expect(content.find("numpy==1.26.0") != std::string::npos, "requirements keep numpy", passed, failed); @@ -62,8 +66,7 @@ int main() { out.close(); DependencyPanelState panel; - std::string log; - refreshDependencies(panel, dir.string(), log); + refreshDependencies(panel, dir.string(), notifications, vulnDb); for (auto it = panel.deps.begin(); it != panel.deps.end(); ) { if (it->name == "react") { @@ -75,7 +78,7 @@ int main() { ++it; } } - bool ok = writeDependenciesForSource("package.json", dir.string(), panel.deps, log); + bool ok = writeDependenciesForSource("package.json", dir.string(), panel.deps, notifications); expect(ok, "package.json writeback ok", passed, failed); nlohmann::json j; @@ -90,10 +93,9 @@ int main() { // edge case: unsupported writeback { - std::string log; std::vector deps; deps.push_back({"Boost", "", "CMakeLists.txt"}); - bool ok = writeDependenciesForSource("CMakeLists.txt", dir.string(), deps, log); + bool ok = writeDependenciesForSource("CMakeLists.txt", dir.string(), deps, notifications); expect(!ok, "unsupported writeback returns false", passed, failed); } diff --git a/editor/tests/step142_test.cpp b/editor/tests/step142_test.cpp index 3673272..f0da8ac 100644 --- a/editor/tests/step142_test.cpp +++ b/editor/tests/step142_test.cpp @@ -1,6 +1,7 @@ // Step 142 TDD Test: Emacs package browser #include "EmacsPackageBrowser.h" #include "EmacsIntegration.h" +#include "NotificationSystem.h" #include static void expect(bool cond, const std::string& name, int& passed, int& failed) { @@ -27,9 +28,9 @@ int main() { MockEmacsConnection mock; EmacsPackageBrowserState state; - std::string log; + NotificationSystem notifications; - refreshEmacsPackages(state, mock, log); + refreshEmacsPackages(state, mock, notifications); expect(!state.packages.empty(), "packages loaded", passed, failed); const EmacsPackageEntry* usePkg = findPkg(state.packages, "use-package"); @@ -44,7 +45,7 @@ int main() { expect(magitPkg->status == "available", "available status set", passed, failed); } - bool loadOk = loadEmacsPackage(mock, "magit", log); + bool loadOk = loadEmacsPackage(mock, "magit", notifications); expect(loadOk, "load package command", passed, failed); expect(mock.getLastSentCommand().find("(require 'magit)") != std::string::npos, "require command sent", passed, failed); diff --git a/editor/tests/step143_test.cpp b/editor/tests/step143_test.cpp index 8141507..c241694 100644 --- a/editor/tests/step143_test.cpp +++ b/editor/tests/step143_test.cpp @@ -1,5 +1,6 @@ // Step 143 TDD Test: Elisp function discovery and indexing #include "EmacsFunctionDiscovery.h" +#include "NotificationSystem.h" #include static void expect(bool cond, const std::string& name, int& passed, int& failed) { @@ -17,21 +18,21 @@ int main() { int failed = 0; MockEmacsConnection mock; - std::string log; + NotificationSystem notifications; std::string error; - auto packages = queryEmacsPackageList(mock, false, log, error); + auto packages = queryEmacsPackageList(mock, false, notifications, error); expect(!packages.empty(), "loaded packages fetched", passed, failed); - auto funcs = queryEmacsFunctions(mock, "^use-package", log); + auto funcs = queryEmacsFunctions(mock, "^use-package", notifications); expect(!funcs.empty(), "apropos returns functions", passed, failed); - auto doc = queryEmacsFunctionDoc(mock, "use-package", log); + auto doc = queryEmacsFunctionDoc(mock, "use-package", notifications); expect(!doc.signature.empty(), "describe-function signature", passed, failed); expect(!doc.doc.empty(), "describe-function docstring", passed, failed); EmacsFunctionIndex index; - refreshEmacsFunctionIndex(index, mock, packages, log); + refreshEmacsFunctionIndex(index, mock, packages, notifications); expect(index.functionsByPackage.count("use-package") > 0, "index contains use-package", passed, failed); Module module("mod1", "test", "elisp"); diff --git a/editor/tests/step144_test.cpp b/editor/tests/step144_test.cpp index fe7d829..a651c3e 100644 --- a/editor/tests/step144_test.cpp +++ b/editor/tests/step144_test.cpp @@ -1,5 +1,6 @@ // Step 144 TDD Test: Emacs keybinding deep integration #include "EmacsKeybinding.h" +#include "NotificationSystem.h" #include static void expect(bool cond, const std::string& name, int& passed, int& failed) { @@ -18,29 +19,29 @@ int main() { MockEmacsConnection mock; EmacsKeybindingState state; - std::string log; + NotificationSystem notifications; - bool prefixOk = emacsHandleKeySequence(state, mock, "C-x", log); + bool prefixOk = emacsHandleKeySequence(state, mock, "C-x", notifications); expect(prefixOk, "prefix handled", passed, failed); expect(state.prefix == "C-x", "prefix stored", passed, failed); - bool seqOk = emacsHandleKeySequence(state, mock, "C-s", log); + bool seqOk = emacsHandleKeySequence(state, mock, "C-s", notifications); expect(seqOk, "sequence handled", passed, failed); expect(state.prefix.empty(), "prefix cleared", passed, failed); expect(state.lastCommand == "save-buffer", "command resolved", passed, failed); expect(mock.getLastSentCommand().find("call-interactively") != std::string::npos, "call-interactively sent", passed, failed); - bool mxOk = emacsHandleKeySequence(state, mock, "M-x", log); + bool mxOk = emacsHandleKeySequence(state, mock, "M-x", notifications); expect(mxOk, "M-x handled", passed, failed); expect(state.minibufferActive, "minibuffer active", passed, failed); std::snprintf(state.minibufferBuf, sizeof(state.minibufferBuf), "find-file"); - bool mbOk = emacsExecuteMinibuffer(state, mock, log); + bool mbOk = emacsExecuteMinibuffer(state, mock, notifications); expect(mbOk, "minibuffer command executed", passed, failed); expect(!state.minibufferActive, "minibuffer cleared", passed, failed); - updateEmacsModeLine(state, mock, 2.0, log); + updateEmacsModeLine(state, mock, 2.0, notifications); expect(!state.modeLine.empty(), "mode line updated", passed, failed); std::cout << "\n=== Step 144 Results: " << passed << " passed, " diff --git a/editor/tests/step245_test.cpp b/editor/tests/step245_test.cpp new file mode 100644 index 0000000..14f2b09 --- /dev/null +++ b/editor/tests/step245_test.cpp @@ -0,0 +1,126 @@ +// Step 245 TDD Test: HeadlessEditorState — agent API without ImGui/SDL +#include "HeadlessEditorState.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; + } +} + +int main() { + int passed = 0; + int failed = 0; + + // 1. Construct headless state without crash + HeadlessEditorState state; + state.workspaceRoot = "/tmp/test"; + state.defaultLanguage = "python"; + expect(state.active() == nullptr, "no active buffer initially", + passed, failed); + + // 2. Open a buffer with Python source + std::string src = "def hello(name):\n return 'hi ' + name\n"; + auto* buf = state.openBuffer("test.py", src, "python"); + expect(buf != nullptr, "buffer opened", passed, failed); + expect(state.active() == buf, "buffer is active", passed, failed); + expect(state.isStructured(), "buffer is structured", passed, failed); + expect(state.activeAST() != nullptr, "AST parsed", passed, failed); + + // 3. Parse source via RPC + json parseReq = { + {"jsonrpc", "2.0"}, {"id", 1}, {"method", "parseSource"}, + {"params", {{"source", "x = 42"}, {"language", "python"}}} + }; + // Set role to Refactor so parseSource is permitted + state.setAgentRole("test-agent", AgentRole::Refactor); + json parseRes = state.processAgentRequest(parseReq, "test-agent"); + expect(parseRes.contains("result"), "parseSource returns result", + passed, failed); + expect(parseRes["result"].contains("ast"), "parseSource has AST", + passed, failed); + + // 4. Get AST via RPC + json getReq = { + {"jsonrpc", "2.0"}, {"id", 2}, {"method", "getAST"} + }; + json getRes = state.processAgentRequest(getReq, "test-agent"); + expect(getRes.contains("result"), "getAST returns result", + passed, failed); + expect(getRes["result"].contains("ast"), "getAST has ast field", + passed, failed); + + // 5. Run pipeline via RPC + json pipeReq = { + {"jsonrpc", "2.0"}, {"id", 3}, {"method", "runPipeline"}, + {"params", {{"source", "def foo(): pass"}, + {"sourceLanguage", "python"}, + {"targetLanguage", "cpp"}}} + }; + json pipeRes = state.processAgentRequest(pipeReq, "test-agent"); + expect(pipeRes.contains("result"), "runPipeline returns result", + passed, failed); + expect(pipeRes["result"].contains("generatedCode"), + "pipeline generates code", passed, failed); + + // 6. Mutation via RPC — rename the module + Module* ast = state.activeAST(); + std::string rootId = ast ? ast->id : ""; + expect(!rootId.empty(), "root node has id", passed, failed); + + json mutReq = { + {"jsonrpc", "2.0"}, {"id", 4}, {"method", "applyMutation"}, + {"params", {{"type", "setProperty"}, {"nodeId", rootId}, + {"property", "name"}, {"value", "renamed"}}} + }; + json mutRes = state.processAgentRequest(mutReq, "test-agent"); + expect(mutRes.contains("result"), "mutation returns result", + passed, failed); + expect(mutRes["result"].value("success", false), + "mutation succeeded", passed, failed); + + // 7. Permission check — Linter cannot mutate + state.setAgentRole("linter-agent", AgentRole::Linter); + json mutReq2 = { + {"jsonrpc", "2.0"}, {"id", 5}, {"method", "applyMutation"}, + {"params", {{"type", "setProperty"}, {"nodeId", rootId}, + {"property", "name"}, {"value", "blocked"}}} + }; + json mutRes2 = state.processAgentRequest(mutReq2, "linter-agent"); + expect(mutRes2.contains("error"), "linter mutation blocked", + passed, failed); + + // 8. Ping + json pingReq = {{"jsonrpc", "2.0"}, {"id", 6}, {"method", "ping"}}; + json pingRes = state.processAgentRequest(pingReq, "test-agent"); + expect(pingRes.contains("result"), "ping returns result", + passed, failed); + + // 9. getSessionInfo + json infoReq = { + {"jsonrpc", "2.0"}, {"id", 7}, {"method", "getSessionInfo"} + }; + json infoRes = state.processAgentRequest(infoReq, "test-agent"); + expect(infoRes.contains("result"), "getSessionInfo returns result", + passed, failed); + expect(infoRes["result"].value("mode", "") == "headless", + "mode is headless", passed, failed); + + // 10. Buffer management — close and reopen + state.closeBuffer("test.py"); + expect(state.active() == nullptr, "buffer closed", passed, failed); + expect(!state.isStructured(), "no structured after close", + passed, failed); + + std::cout << "\n=== Step 245 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md new file mode 100644 index 0000000..3fc07ac --- /dev/null +++ b/progress.md @@ -0,0 +1,25 @@ +# Sprint 9 Progress — Agent-First Tooling + +## Phase 9a: Standalone MCP Server + +### Step 245: HeadlessEditorState (no ImGui dependency) +**Status:** PASS (20/20 tests) + +Created the headless agent API surface — same method interface as EditorState +but with zero GUI dependencies (no ImGui, no SDL, no WebSocket). + +**Files created:** +- `editor/src/ASTUtils.h` — Pure AST utility functions extracted from EditorUtils.h + (cloneModule, isAnnotationNode, countAnnotationNodes, findNodeById, findNodeAtPosition) +- `editor/src/HeadlessEditorState.h` — Lightweight state: HeadlessAgentState, + HeadlessLibraryState, HeadlessBufferState, buffer management, orchestrator sync +- `editor/src/HeadlessAgentRPCHandler.h` — Full RPC dispatch mirroring + AgentRPCHandler.h (20+ methods: getAST, parseSource, runPipeline, applyMutation, + applyBatch, projectLanguage, generateCode, workflow recording, etc.) +- `editor/tests/step245_test.cpp` — 20 test cases covering construction, buffer + management, RPC dispatch, permission enforcement, pipeline execution + +**Key design decisions:** +- JSON roundtrip cloning (matching EditorUtils.h) instead of manual deep copy +- Separate HeadlessAgentState (no AgentMarketplace/WebSocket dependency chain) +- All files under 600-line architecture limit diff --git a/sprint9_plan.md b/sprint9_plan.md new file mode 100644 index 0000000..4fe4201 --- /dev/null +++ b/sprint9_plan.md @@ -0,0 +1,427 @@ +# Sprint 9: Agent-First Tooling — Plan + +> **Goal:** Make Whetstone a practical tool that AI agents use from a terminal +> to write better code with fewer tokens and fewer errors. Strip away the GUI +> dependency. Optimize every RPC round-trip for agent consumption. Ship a +> standalone MCP server that Claude Code (or any MCP client) can talk to +> directly. +> +> **Prerequisites:** Sprint 8 complete (244 steps). Full agent API (25+ RPC +> methods), MCP server/bridge headers, orchestrator, eval harness, and +> pipeline tool all in place. +> +> **Key insight:** Agents don't need squiggly lines, floating windows, or +> syntax highlighting. They need error codes, AST node references, structured +> diagnostics, and compact diffs. The entire LSP surface can be reduced to +> what's actionable in a JSON response. +> +> **Key themes:** +> 1. Headless-first: `whetstone_mcp` as a standalone binary, no SDL required +> 2. Agent-optimized RPC: compact responses, token budgets, delta encoding +> 3. Lean diagnostics: error codes + node references, not human-readable prose +> 4. File-level operations: create, read, write, diff without a GUI +> 5. Real integration testing with Claude Code via MCP + +--- + +## Phase 9a: Standalone MCP Server (Steps 245–249) + +Ship `whetstone_mcp` as a headless binary that any MCP client can launch. +No SDL, no ImGui, no OpenGL. Just JSON-RPC over stdio. + +- [ ] **Step 245: Headless EditorState (no ImGui dependency)** + Extract a `HeadlessEditorState` that provides the agent API surface without + any ImGui or SDL includes: + - Wraps the AST engine, tree-sitter parser, pipeline, and code generators + - Provides `processAgentRequest(json, sessionId)` identical to EditorState + - No `ImGui::GetTime()` — uses `std::chrono` for timestamps + - No notification toasts — logs to stderr or collects in a vector + - Shares all headers: ASTNode.h, Pipeline.h, AgentRPCHandler.h (refactored + to accept a trait/interface rather than full EditorState) + - Test: HeadlessEditorState can parse source, mutate AST, generate code + without linking SDL or ImGui + *New:* `editor/src/HeadlessEditorState.h` + +- [ ] **Step 246: mcp_main.cpp entry point** + Create the standalone MCP server executable: + - `main()` creates HeadlessEditorState + MCPBridge + - Wires `processAgentRequest` as the RPC callback + - Wires resource reader for `whetstone://` URIs + - Calls `bridge.runStdio()` — reads Content-Length framed JSON-RPC + - CLI flags: `--workspace ` (set working directory), + `--language ` (default language), `--verbose` (stderr logging) + - Graceful shutdown on SIGINT/SIGTERM + - Test: launch process, send `initialize` + `tools/list`, verify 10+ tools + *New:* `editor/src/mcp_main.cpp` + *Modifies:* `editor/CMakeLists.txt` (add `whetstone_mcp` target, no SDL link) + +- [ ] **Step 247: File operation tools for MCP** + Agents need to create, read, write, and diff files without a GUI: + - `whetstone_file_read` — read file contents (with optional line range) + - `whetstone_file_write` — write content to a file (creates if needed) + - `whetstone_file_create` — create a new file with optional template + - `whetstone_file_diff` — unified diff between current buffer and disk + - `whetstone_workspace_list` — list files in workspace (with glob filter) + - All operations respect workspace root from `--workspace` + - RPC methods added to AgentRPCHandler, MCP tools registered in MCPServer + - Test: create file via RPC, read it back, diff after modification + *Modifies:* `AgentRPCHandler.h`, `MCPServer.h` + +- [ ] **Step 248: Compact AST response format** + Full AST dumps waste tokens. Add a compact mode: + - `getAST` gains `compact: true` parameter — returns abbreviated nodes: + `{id, type, name, line, children: [ids]}` instead of full property dump + - `getASTSubtree` — new method: returns only the subtree rooted at a nodeId + - `getASTDiff` — new method: returns only nodes changed since last query + (uses a version counter incremented on each mutation) + - Response includes `tokenEstimate` field (rough char count / 4) + - Test: compact AST is <30% the size of full AST for a 50-function module + *Modifies:* `AgentRPCHandler.h` + +- [ ] **Step 249: MCP server integration tests** + End-to-end tests for the standalone MCP server: + 1. Launch `whetstone_mcp`, complete MCP handshake (initialize/initialized) + 2. `tools/list` returns all registered tools with valid schemas + 3. `tools/call whetstone_get_ast` on empty state returns valid response + 4. `tools/call whetstone_run_pipeline` with Python source → generates code + 5. `resources/read whetstone://diagnostics` returns structured diagnostics + 6. `prompts/list` returns all registered prompts + 7. File operations: create, read, write, diff cycle + 8. Compact AST vs full AST size comparison + *New:* `step249_test.cpp` + +--- + +## Phase 9b: Agent-Optimized Diagnostics (Steps 250–253) + +Replace human-readable LSP diagnostics with agent-optimized structured +output. Agents need error codes and AST references, not squiggly lines. + +- [ ] **Step 250: Structured diagnostic format** + Define a compact diagnostic format for agent consumption: + ```json + { + "code": "E0302", + "severity": "error", + "nodeId": "func_42", + "line": 17, "col": 4, + "message": "type mismatch: expected int, got str", + "fix": {"type": "setProperty", "nodeId": "func_42", "property": "returnType", "value": "str"} + } + ``` + - `getDiagnostics` RPC method: returns array of structured diagnostics + - Each diagnostic includes: error code, severity, node reference, location, + short message, and optional machine-applicable fix + - Combines tree-sitter parse errors, Whetstone validation errors, and + LSP diagnostics into one unified stream + - Severity levels: error, warning, info, hint (numeric codes for filtering) + - Test: parse invalid Python, verify diagnostics have nodeIds and fix hints + *New:* `editor/src/StructuredDiagnostics.h` + *Modifies:* `AgentRPCHandler.h`, `MCPServer.h` + +- [ ] **Step 251: Quick-fix actions as RPC mutations** + Turn diagnostic fixes into one-shot RPC calls: + - `applyQuickFix` method: takes diagnostic code + nodeId, applies the + suggested fix as a mutation + - `getQuickFixes` method: for a given nodeId, returns all applicable fixes + as mutation objects the agent can review before applying + - Fix categories: type correction, missing import, unused variable removal, + rename suggestion, missing return statement + - Each fix is a concrete mutation (or batch) — no human interpretation needed + - Test: parse code with type error, get fix, apply fix, verify diagnostic clears + *Modifies:* `AgentRPCHandler.h`, `StructuredDiagnostics.h` + +- [ ] **Step 252: Diagnostic delta streaming** + After a mutation, agents shouldn't re-fetch all diagnostics: + - `getDiagnosticsDelta` method: returns only diagnostics that changed since + a given version number + - Response: `{added: [...], removed: [...], version: N}` + - Version counter increments on every AST mutation or reparse + - Removed diagnostics identified by their `(code, nodeId, line)` tuple + - Enables efficient "mutate → check if error cleared" loops + - Test: introduce error, get diagnostics, fix error, get delta showing removal + *Modifies:* `AgentRPCHandler.h`, `StructuredDiagnostics.h` + +- [ ] **Step 253: Diagnostic and delta tests** + Comprehensive tests for the diagnostic pipeline: + 1. Parse valid code → zero diagnostics + 2. Parse invalid code → structured diagnostics with nodeIds + 3. Diagnostics include fix suggestions where applicable + 4. `applyQuickFix` resolves the diagnostic + 5. Delta after fix shows removal + 6. Delta after introducing new error shows addition + 7. Combined tree-sitter + Whetstone diagnostics in one stream + 8. Severity filtering works + *New:* `step253_test.cpp` + +--- + +## Phase 9c: Token-Efficient Agent Protocol (Steps 254–257) + +Minimize token cost per interaction. Every byte in a response costs the +agent (and the user) money and context window space. + +- [ ] **Step 254: Response budget system** + Let agents request only what they need: + - All query methods gain optional `budget` parameter (max response chars) + - If response exceeds budget, it's truncated with `"truncated": true` and + a `"continuation"` token for paginated follow-up + - `getAST` with budget: returns top-level nodes first, details on demand + - `getDiagnostics` with budget: returns highest-severity first + - Default budget: unlimited (backward compatible) + - Test: getAST with budget=500 returns truncated response with continuation + *Modifies:* `AgentRPCHandler.h` + +- [ ] **Step 255: Symbol-only mode for scope queries** + `getInScopeSymbols` currently returns full node data. Add lean mode: + - `symbols` parameter (default): returns `[{name, type, kind, nodeId}]` + - `detailed` parameter: returns full node JSON (current behavior) + - `getCallHierarchy` lean mode: returns `{callers: [name], callees: [name]}` + instead of full node trees + - `getDependencyGraph` lean mode: adjacency list of nodeIds only + - Test: lean scope query is <20% the size of detailed query + *Modifies:* `AgentRPCHandler.h` + +- [ ] **Step 256: Batch query endpoint** + Agents often need AST + diagnostics + scope in one round-trip: + - `batchQuery` method: accepts array of query requests, returns array of + results in the same order + - Single JSON-RPC call, single response — no protocol overhead per query + - Each sub-query is a normal method name + params + - Errors in one sub-query don't fail the batch + - Test: batch of [getAST, getDiagnostics, getInScopeSymbols] returns 3 results + *Modifies:* `AgentRPCHandler.h`, `MCPServer.h` + +- [ ] **Step 257: Token efficiency tests + benchmarks** + Measure and verify token savings: + 1. Compact AST vs full AST: measure ratio for 10/50/200 node modules + 2. Lean scope vs detailed: measure ratio + 3. Delta diagnostics vs full: measure ratio + 4. Budget truncation: verify continuation works across 3 pages + 5. Batch query vs sequential: measure total bytes + 6. Benchmark: time for 100 parse→mutate→diagnose cycles in headless mode + *New:* `step257_test.cpp` + +--- + +## Phase 9d: Multi-File Project Support (Steps 258–262) + +Agents work on projects, not single files. Add project-level operations. + +- [ ] **Step 258: Project model and workspace indexing** + HeadlessEditorState gains project awareness: + - `ProjectState`: tracks open files, workspace root, language per file + - `openFile` / `closeFile` RPC methods manage multiple buffers + - `listBuffers` returns all open files with language and dirty status + - `setActiveBuffer` switches which buffer `getAST` etc. operate on + - Workspace scan on startup: index file paths (not content) for fast lookup + - Test: open 3 files, switch active, verify getAST returns correct AST + *New:* `editor/src/ProjectState.h` + *Modifies:* `HeadlessEditorState.h`, `AgentRPCHandler.h` + +- [ ] **Step 259: Cross-file symbol resolution** + When an agent queries scope, include symbols from other open files: + - `getInScopeSymbols` gains `crossFile: true` parameter + - Scans all open buffers for exported symbols (functions, classes, modules) + - Returns source file path with each cross-file symbol + - Import graph: track which files import which, update on file change + - Test: file A imports from file B, scope query in A includes B's exports + *Modifies:* `AgentRPCHandler.h`, `ProjectState.h` + +- [ ] **Step 260: Project-wide diagnostics** + Diagnostics across all open files in one call: + - `getProjectDiagnostics` method: returns diagnostics grouped by file path + - Compact format: `{"/path/foo.py": [{code, line, message}], ...}` + - Includes cross-file errors (undefined import, circular dependency) + - Optional severity filter and file path glob filter + - Test: two files with errors, project diagnostics returns both sets + *Modifies:* `AgentRPCHandler.h`, `StructuredDiagnostics.h` + +- [ ] **Step 261: Project-wide search and refactor** + Agents need to find and rename across files: + - `searchProject` method: find symbol references across all open files + by name or nodeId, returns `[{file, line, col, nodeId, context}]` + - `renameSymbol` method: rename a symbol across all files that reference it + returns a preview of all changes, then applies on confirmation + - Uses the import graph to find cross-file references + - Test: rename a function, verify all call sites across 2 files are updated + *Modifies:* `AgentRPCHandler.h`, `ProjectState.h` + +- [ ] **Step 262: Multi-file project tests** + Full integration tests for project operations: + 1. Open workspace with 5 Python files + 2. Index finds all files + 3. Open 3 files, verify buffer list + 4. Cross-file scope resolution works + 5. Project-wide diagnostics aggregates correctly + 6. Search finds references across files + 7. Rename updates all references atomically + 8. Close file removes it from scope resolution + *New:* `step262_test.cpp` + +--- + +## Phase 9e: Claude Code MCP Integration (Steps 263–267) + +Make `whetstone_mcp` work as a real MCP server that Claude Code can use. +This is the "install a plugin and activate you in my terminal" goal. + +- [ ] **Step 263: MCP server configuration for Claude Code** + Create the config and documentation for Claude Code integration: + - `claude_desktop_config.json` snippet for `whetstone_mcp` server + - Auto-detection: if `whetstone_mcp` is in PATH, register it + - Server args: `--workspace $(pwd)` to set project root + - Startup health check: verify MCP handshake completes in <2s + - Test: config generation produces valid JSON matching Claude Code schema + *New:* `editor/src/MCPConfig.h` + *New:* `mcp-config.example.json` + +- [ ] **Step 264: Agent workflow prompts for real coding tasks** + Expand MCP prompts beyond annotations to practical coding workflows: + - `implement_function` — spec → AST → code → validate → diagnostics loop + - `fix_errors` — get diagnostics → apply quick-fixes → verify + - `add_tests` — analyze function → generate test cases → write test file + - `refactor_extract` — select code region → extract to new function + - `explain_code` — get AST subtree → describe structure and behavior + - Each prompt guides the agent through the optimal tool sequence + - Test: each prompt generates valid multi-step instruction messages + *Modifies:* `MCPServer.h` + +- [ ] **Step 265: Tool result formatting for LLM consumption** + Optimize tool results so Claude Code can parse them efficiently: + - Error results include actionable next steps (not just error messages) + - Success results include a one-line summary before detailed JSON + - Large results (>2000 chars) auto-paginate with continuation tokens + - Code blocks in results are language-tagged for model comprehension + - Diagnostics include human-readable explanation alongside error codes + - Test: tool results match expected format for 10 common operations + *Modifies:* `MCPServer.h`, `AgentRPCHandler.h` + +- [ ] **Step 266: End-to-end MCP workflow test** + Simulate a complete coding session through MCP: + 1. Initialize MCP server with a workspace + 2. Create a new Python file via `whetstone_file_create` + 3. Write function stubs via `whetstone_file_write` + 4. Parse the file via `whetstone_run_pipeline` + 5. Get AST and verify structure via `whetstone_get_ast` + 6. Mutate: add a parameter via `whetstone_mutate` + 7. Get diagnostics: verify type-awareness via `whetstone_get_diagnostics` + 8. Generate code from spec via `whetstone_generate_code` + 9. Write generated code to new file + 10. Project-wide diagnostics show no errors + *New:* `step266_test.cpp` + +- [ ] **Step 267: MCP integration documentation** + Write the integration guide (in code comments, not standalone docs): + - MCPConfig.h: detailed comments explaining setup for Claude Code, + Cursor, Continue, and generic MCP clients + - Tool descriptions: ensure every MCP tool description is clear enough + that an LLM can use it without examples + - Example session transcript as a code comment in mcp_main.cpp + - Error recovery guide: common errors and how the agent should respond + *Modifies:* `mcp_main.cpp`, `MCPServer.h`, `MCPConfig.h` + +--- + +## Phase 9f: Performance & Stability (Steps 268–272) + +Agents hit the RPC surface hard and fast. Make sure it can keep up. + +- [ ] **Step 268: Headless performance profiling** + Establish baseline performance for agent workloads: + - Benchmark harness: measures time for parse, mutate, generate, diagnose + - Target: parse 1000-line file in <100ms, single mutation in <5ms, + getAST (compact) in <10ms, diagnostics in <20ms + - Profile memory: HeadlessEditorState with 10 open files <50MB + - Report: JSON output with p50/p95/p99 latencies per operation + - Test: all operations meet target latencies + *New:* `editor/src/HeadlessBenchmark.h` + *New:* `step268_test.cpp` + +- [ ] **Step 269: Incremental reparse optimization** + Don't reparse the entire file on every mutation: + - Track byte ranges changed by each mutation + - Use tree-sitter's incremental parse: `ts_parser_parse` with edit info + - Only regenerate diagnostics for affected subtrees + - Cache: last parse tree kept in memory, reused for incremental update + - Test: editing one function in a 1000-line file is >5x faster than full reparse + *Modifies:* `HeadlessEditorState.h`, `Pipeline.h` or relevant parser header + +- [ ] **Step 270: AST mutation batching optimization** + `applyBatch` should be faster than N individual mutations: + - Defer reparse until batch completes (single reparse at end) + - Defer diagnostic regeneration until batch completes + - Coalesce adjacent mutations (e.g., two setProperty on same node) + - Validation: run structural validation once after batch, not per-mutation + - Test: batch of 20 mutations is >3x faster than 20 individual calls + *Modifies:* `AgentRPCHandler.h` + +- [ ] **Step 271: Stress test suite** + Verify stability under sustained agent load: + 1. 1000 sequential parse→mutate→diagnose cycles without crash or leak + 2. 100 batch mutations of 50 operations each + 3. Rapid buffer switching (10 files, random access pattern) + 4. Concurrent-style stress: interleaved reads and writes + 5. Large file handling: 5000-line file parse + mutation + 6. Memory stable: RSS doesn't grow >2x after 1000 cycles + *New:* `step271_test.cpp` + +- [ ] **Step 272: Error recovery and graceful degradation** + Agents will send bad requests. Handle them gracefully: + - Malformed JSON → clean error, no crash + - Invalid nodeId → error with suggestion (nearest valid node) + - Mutation on deleted node → error explaining node was removed + - Parse failure → partial AST with error markers, not empty response + - OOM protection: if AST exceeds size limit, refuse new insertions + - Test: 10 different error scenarios all return structured errors, no crashes + *Modifies:* `AgentRPCHandler.h`, `HeadlessEditorState.h` + +--- + +## Summary + +| Phase | Steps | Description | +|-------|-------|-------------| +| 9a | 245–249 | Standalone MCP server (`whetstone_mcp` binary) | +| 9b | 250–253 | Agent-optimized diagnostics (error codes, nodeIds, fixes) | +| 9c | 254–257 | Token-efficient protocol (budgets, deltas, batching) | +| 9d | 258–262 | Multi-file project support | +| 9e | 263–267 | Claude Code MCP integration | +| 9f | 268–272 | Performance, stability, and stress testing | + +**Total: 28 steps across 6 phases.** + +--- + +## Architecture Notes + +- **No SDL/ImGui dependency for agent path:** `whetstone_mcp` links only + against tree-sitter, nlohmann-json, and the Whetstone core headers. + This means it builds and runs on any Linux box, CI server, or container + without display server or GPU. +- **HeadlessEditorState vs EditorState:** HeadlessEditorState is not a + subclass — it's a parallel implementation that shares the same headers + (ASTNode.h, Pipeline.h, CodeGen.h, etc.) but replaces ImGui-dependent + code with std::chrono timestamps and stderr logging. +- **Agent RPC interface trait:** AgentRPCHandler.h should be refactored to + accept an interface (getAST, mutateAST, getDiagnostics) rather than + taking `EditorState&` directly. Both EditorState and HeadlessEditorState + implement this interface. This avoids code duplication. +- **600-line limit enforced:** All new headers must pass file_limits_test. +- **Test-first:** Every step has real assertions testing actual behavior. + No placeholder tests. +- **MCP protocol version:** `2024-11-05` (current stable). Content-Length + framing over stdio. +- **Token accounting:** Responses include `tokenEstimate` (chars/4) so + agents can make informed decisions about what to query. + +--- + +## Sprint 10 Preview (for planning) + +**Sprint 10: Language Intelligence & GUI Rehabilitation** +1. LSP client for headless mode (clangd, pyright, rust-analyzer) +2. Language-specific code actions (Go imports, Rust borrow fixes) +3. GUI overhaul: pin floating windows, sane defaults, usable color scheme +4. Terminal UI mode (TUI) as alternative to ImGui GUI +5. Plugin system: load language support and tools dynamically