diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 53eff46..f99fe4c 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1565,4 +1565,36 @@ target_link_libraries(step262_test PRIVATE tree_sitter_java tree_sitter_rust tree_sitter_go tree_sitter_org) +# Step 263: Save buffer to disk +add_executable(step263_test tests/step263_test.cpp) +target_include_directories(step263_test PRIVATE src) +target_link_libraries(step263_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 264: Undo/redo for headless mode +add_executable(step264_test tests/step264_test.cpp) +target_include_directories(step264_test PRIVATE src) +target_link_libraries(step264_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) + +add_executable(step265_test tests/step265_test.cpp) +target_include_directories(step265_test PRIVATE src) +target_link_libraries(step265_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 060cedd..6a66fe1 100644 --- a/editor/src/AgentPermissionPolicy.h +++ b/editor/src/AgentPermissionPolicy.h @@ -70,7 +70,11 @@ struct AgentPermissionPolicy { method == "applyQuickFix" || method == "openFile" || method == "closeFile" || - method == "renameSymbol") { + method == "renameSymbol" || + method == "saveBuffer" || + method == "saveAllBuffers" || + method == "undo" || + method == "redo") { return role == AgentRole::Refactor || role == AgentRole::Generator; } diff --git a/editor/src/HeadlessAgentRPCHandler.h b/editor/src/HeadlessAgentRPCHandler.h index b7b8025..dcc6636 100644 --- a/editor/src/HeadlessAgentRPCHandler.h +++ b/editor/src/HeadlessAgentRPCHandler.h @@ -317,6 +317,9 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, "agent-mutation:" + type, affectedIds, state.agentActorLabel(sessionId)); state.active()->versionTracker.recordMutation(affectedIds); + // Record post-mutation state for undo + state.active()->undoStack.record( + state.active()->editBuf, state.activeAST()); } return headlessRpcResult(id, { {"success", true}, {"warning", res.warning}, @@ -376,6 +379,9 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, for (const auto& m : mutations) if (!m.nodeId.empty()) batchIds.push_back(m.nodeId); state.active()->versionTracker.recordMutation(batchIds); + // Record post-mutation state for undo + state.active()->undoStack.record( + state.active()->editBuf, state.activeAST()); } return headlessRpcResult(id, {{"success", true}, {"appliedCount", batchRes.appliedCount}, @@ -1176,6 +1182,7 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, return headlessRpcError(id, -32602, "Missing oldName or newName parameter"); + // Collect changes across all open buffers std::vector allChanges; for (const auto& [path, buf] : state.bufferStates) { @@ -1254,11 +1261,22 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, } } - // Mark affected buffers as modified + // Mark affected buffers as modified, regenerate editBuf, record undo for (const auto& f : affectedFiles) { auto it = state.bufferStates.find(f); - if (it != state.bufferStates.end()) + if (it != state.bufferStates.end()) { it->second->modified = true; + // Regenerate code from the modified AST + Module* bufAST = it->second->sync.getAST(); + if (bufAST) { + Pipeline pipeline; + it->second->editBuf = pipeline.generate( + bufAST, it->second->language); + // Record post-rename state for undo + it->second->undoStack.record( + it->second->editBuf, bufAST); + } + } } json result = { @@ -1363,6 +1381,168 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, }); } + // --- undo --- + if (method == "undo") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + if (!state.activeBuffer) + return headlessRpcError(id, -32000, "No active buffer"); + auto& stack = state.activeBuffer->undoStack; + if (!stack.canUndo()) + return headlessRpcError(id, -32050, "Nothing to undo"); + const auto& snap = stack.undo(); + // Restore AST from JSON + if (!snap.astJson.is_null()) { + ASTNode* node = fromJson(snap.astJson); + if (node && node->conceptType == "Module") { + state.activeBuffer->sync.setAST( + std::unique_ptr( + static_cast(node))); + state.activeBuffer->orchestratorDirty = true; + } else { + deleteTree(node); + } + } + state.activeBuffer->editBuf = snap.text; + state.activeBuffer->modified = true; + return headlessRpcResult(id, { + {"success", true}, + {"undoDepth", stack.undoDepth()}, + {"redoDepth", stack.redoDepth()} + }); + } + + // --- redo --- + if (method == "redo") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + if (!state.activeBuffer) + return headlessRpcError(id, -32000, "No active buffer"); + auto& stack = state.activeBuffer->undoStack; + if (!stack.canRedo()) + return headlessRpcError(id, -32050, "Nothing to redo"); + const auto& snap = stack.redo(); + if (!snap.astJson.is_null()) { + ASTNode* node = fromJson(snap.astJson); + if (node && node->conceptType == "Module") { + state.activeBuffer->sync.setAST( + std::unique_ptr( + static_cast(node))); + state.activeBuffer->orchestratorDirty = true; + } else { + deleteTree(node); + } + } + state.activeBuffer->editBuf = snap.text; + state.activeBuffer->modified = true; + return headlessRpcResult(id, { + {"success", true}, + {"undoDepth", stack.undoDepth()}, + {"redoDepth", stack.redoDepth()} + }); + } + + // --- saveBuffer --- + if (method == "saveBuffer") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + auto params = request.contains("params") ? request["params"] + : json::object(); + std::string path = params.value("path", ""); + + // Default to active buffer if no path specified + if (path.empty()) { + if (!state.activeBuffer) + return headlessRpcError(id, -32000, "No active buffer"); + path = state.activeBuffer->path; + } + + auto it = state.bufferStates.find(path); + if (it == state.bufferStates.end()) + return headlessRpcError(id, -32002, + "Buffer not found: " + path); + + auto& buf = it->second; + + // Resolve path against workspace root + std::string writePath = path; + if (!state.workspaceRoot.empty()) { + auto [ok, resolved] = + fileOpsResolvePath(state.workspaceRoot, path); + if (!ok) + return headlessRpcError(id, -32040, resolved); + writePath = resolved; + } + + // Create parent directories if needed + fs::path parentDir = fs::path(writePath).parent_path(); + if (!parentDir.empty()) { + std::error_code ec; + fs::create_directories(parentDir, ec); + } + + // Write editBuf to disk + auto [success, msg, bytes] = fileOpsWrite(writePath, buf->editBuf); + if (!success) + return headlessRpcError(id, -32041, msg); + + buf->modified = false; + return headlessRpcResult(id, { + {"success", true}, {"path", writePath}, + {"bytesWritten", bytes} + }); + } + + // --- saveAllBuffers --- + if (method == "saveAllBuffers") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + + json savedPaths = json::array(); + int savedCount = 0; + int skippedCount = 0; + + for (auto& [path, buf] : state.bufferStates) { + if (!buf->modified) { + ++skippedCount; + continue; + } + + std::string writePath = path; + if (!state.workspaceRoot.empty()) { + auto [ok, resolved] = + fileOpsResolvePath(state.workspaceRoot, path); + if (!ok) { + ++skippedCount; + continue; + } + writePath = resolved; + } + + fs::path parentDir = fs::path(writePath).parent_path(); + if (!parentDir.empty()) { + std::error_code ec; + fs::create_directories(parentDir, ec); + } + + auto [success, msg, bytes] = + fileOpsWrite(writePath, buf->editBuf); + if (success) { + buf->modified = false; + savedPaths.push_back(writePath); + ++savedCount; + } else { + ++skippedCount; + } + } + + return headlessRpcResult(id, { + {"savedCount", savedCount}, + {"skippedCount", skippedCount}, + {"saved", savedPaths} + }); + } + // --- batchQuery --- if (method == "batchQuery") { auto params = request.contains("params") ? request["params"] diff --git a/editor/src/HeadlessEditorState.h b/editor/src/HeadlessEditorState.h index a6b0391..85019e0 100644 --- a/editor/src/HeadlessEditorState.h +++ b/editor/src/HeadlessEditorState.h @@ -61,6 +61,44 @@ struct HeadlessLibraryState { SemanticTags semanticTags; }; +// ----------------------------------------------------------------------- +// HeadlessUndoStack — state-based undo/redo for headless buffers +// ----------------------------------------------------------------------- +struct HeadlessUndoState { + std::string text; + json astJson; +}; + +class HeadlessUndoStack { +public: + // Record current state as a checkpoint + void record(const std::string& text, Module* ast) { + // Truncate any redo states + if (position_ + 1 < (int)states_.size()) + states_.resize(position_ + 1); + HeadlessUndoState s; + s.text = text; + if (ast) s.astJson = toJson(ast); + states_.push_back(std::move(s)); + position_ = (int)states_.size() - 1; + } + + bool canUndo() const { return position_ > 0; } + bool canRedo() const { return position_ + 1 < (int)states_.size(); } + + const HeadlessUndoState& undo() { return states_[--position_]; } + const HeadlessUndoState& redo() { return states_[++position_]; } + + int undoDepth() const { return position_; } + int redoDepth() const { + return (int)states_.size() - position_ - 1; + } + +private: + std::vector states_; + int position_ = -1; +}; + // ----------------------------------------------------------------------- // HeadlessBufferState — lightweight buffer without ImGui widgets // ----------------------------------------------------------------------- @@ -70,6 +108,7 @@ struct HeadlessBufferState { IncrementalOptimizer incrementalOptimizer; ASTVersionTracker versionTracker; DiagnosticVersionTracker diagTracker; + HeadlessUndoStack undoStack; std::string language = "python"; std::string path = "(untitled)"; std::string editBuf; @@ -205,6 +244,8 @@ struct HeadlessEditorState { buf->orchestratorDirty = true; } HeadlessBufferState* raw = buf.get(); + // Record initial state for undo + raw->undoStack.record(raw->editBuf, raw->sync.getAST()); bufferStates[filePath] = std::move(buf); if (!activeBuffer) activeBuffer = raw; return raw; diff --git a/editor/src/MCPServer.h b/editor/src/MCPServer.h index 0418028..db90b42 100644 --- a/editor/src/MCPServer.h +++ b/editor/src/MCPServer.h @@ -879,6 +879,61 @@ private: }; } + // --------------------------------------------------------------- + // Save and undo/redo tools + // --------------------------------------------------------------- + void registerSaveUndoTools() { + // whetstone_save_buffer + tools_.push_back({"whetstone_save_buffer", + "Save a buffer to disk. Writes the current editBuf (code " + "regenerated from AST) to the file path. Clears the " + "modified flag. Defaults to active buffer if no path given.", + {{"type", "object"}, {"properties", { + {"path", {{"type", "string"}, + {"description", + "Buffer path to save (optional, defaults to active)"}}} + }}} + }); + toolHandlers_["whetstone_save_buffer"] = + [this](const json& args) { + return callWhetstone("saveBuffer", args); + }; + + // whetstone_save_all_buffers + tools_.push_back({"whetstone_save_all_buffers", + "Save all modified buffers to disk. Skips unmodified " + "buffers. Returns count of saved and skipped files.", + {{"type", "object"}, {"properties", json::object()}} + }); + toolHandlers_["whetstone_save_all_buffers"] = + [this](const json& args) { + return callWhetstone("saveAllBuffers", args); + }; + + // whetstone_undo + tools_.push_back({"whetstone_undo", + "Undo the last mutation on the active buffer. Restores " + "the previous AST and regenerated code from the snapshot " + "journal. Returns the new undo/redo depths.", + {{"type", "object"}, {"properties", json::object()}} + }); + toolHandlers_["whetstone_undo"] = + [this](const json& args) { + return callWhetstone("undo", args); + }; + + // whetstone_redo + tools_.push_back({"whetstone_redo", + "Redo the last undone mutation on the active buffer. " + "Returns the new undo/redo depths.", + {{"type", "object"}, {"properties", json::object()}} + }); + toolHandlers_["whetstone_redo"] = + [this](const json& args) { + return callWhetstone("redo", args); + }; + } + void registerWhetstoneTools() { registerASTTools(); registerAnnotationTools(); @@ -886,5 +941,6 @@ private: registerDiagnosticTools(); registerBatchTools(); registerProjectTools(); + registerSaveUndoTools(); } }; diff --git a/editor/tests/step263_test.cpp b/editor/tests/step263_test.cpp new file mode 100644 index 0000000..c23280e --- /dev/null +++ b/editor/tests/step263_test.cpp @@ -0,0 +1,373 @@ +// Step 263 TDD Test: Save Buffer to Disk +// +// Tests saveBuffer (regenerate code from AST, write to disk, clear modified) +// and saveAllBuffers (batch save all modified buffers). Both respect the +// workspace root for path resolution. +#include "HeadlessEditorState.h" +#include "MCPServer.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +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); +} + +// Helper: read file content from disk +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) return ""; + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + return content; +} + +int main() { + int passed = 0; + int failed = 0; + + // Create a temp directory for save tests + fs::path tmpDir = fs::temp_directory_path() / "whetstone_step263_test"; + fs::create_directories(tmpDir); + + std::string workspace = tmpDir.string(); + + std::string srcA = + "def greet(name):\n" + " return \"hello \" + name\n"; + + std::string srcB = + "def add(x, y):\n" + " return x + y\n"; + + // --------------------------------------------------------------- + // Test 1: saveBuffer writes editBuf to disk + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = workspace; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "greet.py"}, {"content", srcA}}); + + json resp = rpc(state, "s1", "saveBuffer", + {{"path", "greet.py"}}); + bool success = resp.contains("result") && + resp["result"].value("success", false); + int bytes = resp["result"].value("bytesWritten", 0); + + // Verify file on disk + std::string onDisk = readFile((tmpDir / "greet.py").string()); + bool written = !onDisk.empty(); + + expect(success && bytes > 0 && written, + "saveBuffer writes file to disk (" + + std::to_string(bytes) + " bytes)", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 2: saveBuffer clears the modified flag + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = workspace; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "mod.py"}, {"content", srcA}}); + + // Mark as modified (simulating a mutation) + state.bufferStates["mod.py"]->modified = true; + bool wasMod = state.bufferStates["mod.py"]->modified; + + rpc(state, "s1", "saveBuffer", {{"path", "mod.py"}}); + + bool nowClean = !state.bufferStates["mod.py"]->modified; + expect(wasMod && nowClean, + "saveBuffer clears modified flag", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 3: saveBuffer returns correct response fields + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = workspace; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "fields.py"}, {"content", srcA}}); + + json resp = rpc(state, "s1", "saveBuffer", + {{"path", "fields.py"}}); + bool hasPath = resp["result"].contains("path"); + bool hasBytes = resp["result"].contains("bytesWritten"); + bool hasSuccess = resp["result"].contains("success"); + expect(hasPath && hasBytes && hasSuccess, + "Response has path, bytesWritten, success fields", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 4: saveAllBuffers saves all modified, skips unmodified + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = workspace; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "a.py"}, {"content", srcA}}); + rpc(state, "s1", "openFile", + {{"path", "b.py"}, {"content", srcB}}); + rpc(state, "s1", "openFile", + {{"path", "c.py"}, {"content", srcA}}); + + // Mark only a.py and c.py as modified + state.bufferStates["a.py"]->modified = true; + state.bufferStates["c.py"]->modified = true; + // b.py stays unmodified + + json resp = rpc(state, "s1", "saveAllBuffers"); + int savedCount = resp["result"].value("savedCount", 0); + int skippedCount = resp["result"].value("skippedCount", 0); + + expect(savedCount == 2 && skippedCount == 1, + "saveAllBuffers: saved=" + std::to_string(savedCount) + + " skipped=" + std::to_string(skippedCount), + passed, failed); + } + + // --------------------------------------------------------------- + // Test 5: saveBuffer after mutation — regenerated code on disk + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = workspace; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "rename_save.py"}, {"content", srcA}}); + + // Rename the function + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "salute"}}); + + // Save to disk + rpc(state, "s1", "saveBuffer", + {{"path", "rename_save.py"}}); + + // Read from disk and verify the rename + std::string onDisk = readFile( + (tmpDir / "rename_save.py").string()); + bool hasSalute = onDisk.find("salute") != std::string::npos; + bool noGreet = onDisk.find("greet") == std::string::npos; + + expect(hasSalute && noGreet, + "Saved file contains renamed function 'salute'", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 6: saveBuffer requires Refactor or Generator role + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = workspace; + state.setAgentRole("admin", AgentRole::Refactor); + state.setAgentRole("linter", AgentRole::Linter); + + rpc(state, "admin", "openFile", + {{"path", "perm.py"}, {"content", srcA}}); + + // Linter cannot save + json denied = rpc(state, "linter", "saveBuffer", + {{"path", "perm.py"}}); + bool linterDenied = denied.contains("error"); + + // Refactor can save + json allowed = rpc(state, "admin", "saveBuffer", + {{"path", "perm.py"}}); + bool refactorOk = allowed.contains("result"); + + expect(linterDenied && refactorOk, + "Linter denied, Refactor allowed for saveBuffer", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 7: saveBuffer on active buffer (no path param) + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = workspace; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "active_save.py"}, {"content", srcA}}); + state.bufferStates["active_save.py"]->modified = true; + + // Save without specifying path — should save active buffer + json resp = rpc(state, "s1", "saveBuffer"); + bool success = resp.contains("result") && + resp["result"].value("success", false); + + // Verify file on disk + std::string onDisk = readFile( + (tmpDir / "active_save.py").string()); + bool written = !onDisk.empty(); + + expect(success && written, + "saveBuffer without path saves active buffer", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 8: saveBuffer error on unknown buffer + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = workspace; + state.setAgentRole("s1", AgentRole::Refactor); + + json resp = rpc(state, "s1", "saveBuffer", + {{"path", "nonexistent.py"}}); + bool hasError = resp.contains("error"); + expect(hasError, + "saveBuffer on unknown buffer returns error", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 9: saveAllBuffers clears modified flags on all saved + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = workspace; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "x.py"}, {"content", srcA}}); + rpc(state, "s1", "openFile", + {{"path", "y.py"}, {"content", srcB}}); + state.bufferStates["x.py"]->modified = true; + state.bufferStates["y.py"]->modified = true; + + rpc(state, "s1", "saveAllBuffers"); + + bool xClean = !state.bufferStates["x.py"]->modified; + bool yClean = !state.bufferStates["y.py"]->modified; + expect(xClean && yClean, + "saveAllBuffers clears modified flags on all saved", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 10: saveAllBuffers returns saved file paths + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = workspace; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "p.py"}, {"content", srcA}}); + rpc(state, "s1", "openFile", + {{"path", "q.py"}, {"content", srcB}}); + state.bufferStates["p.py"]->modified = true; + state.bufferStates["q.py"]->modified = true; + + json resp = rpc(state, "s1", "saveAllBuffers"); + bool hasSaved = resp["result"].contains("saved") && + resp["result"]["saved"].is_array(); + int savedSize = hasSaved ? (int)resp["result"]["saved"].size() : 0; + expect(hasSaved && savedSize == 2, + "saveAllBuffers returns saved file paths (" + + std::to_string(savedSize) + ")", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 11: MCP tools registered + // --------------------------------------------------------------- + { + MCPServer mcp; + bool foundSave = false; + bool foundSaveAll = false; + for (const auto& t : mcp.getTools()) { + if (t.name == "whetstone_save_buffer") foundSave = true; + if (t.name == "whetstone_save_all_buffers") + foundSaveAll = true; + } + expect(foundSave && foundSaveAll, + "whetstone_save_buffer and whetstone_save_all_buffers " + "MCP tools registered", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 12: saveBuffer with workspace path resolution + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = workspace; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "sub/deep.py"}, {"content", srcA}}); + state.bufferStates["sub/deep.py"]->modified = true; + + json resp = rpc(state, "s1", "saveBuffer", + {{"path", "sub/deep.py"}}); + bool success = resp.contains("result") && + resp["result"].value("success", false); + + // Verify the subdirectory was created + bool fileExists = fs::exists(tmpDir / "sub" / "deep.py"); + expect(success && fileExists, + "saveBuffer creates parent dirs and writes file", + passed, failed); + } + + // Cleanup + fs::remove_all(tmpDir); + + std::cout << "\n=== Step 263 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/editor/tests/step264_test.cpp b/editor/tests/step264_test.cpp new file mode 100644 index 0000000..525b8f0 --- /dev/null +++ b/editor/tests/step264_test.cpp @@ -0,0 +1,408 @@ +// Step 264 TDD Test: Undo/Redo for Headless Mode +// +// Tests undo and redo RPC methods that use the Orchestrator's snapshot +// journal to restore previous/next AST + editBuf states after mutations. +// Snapshots are recorded automatically before each mutation. +#include "HeadlessEditorState.h" +#include "MCPServer.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 greet(name):\n" + " return \"hello \" + name\n"; + + // --------------------------------------------------------------- + // Test 1: undo with no history returns error + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + json resp = rpc(state, "s1", "undo"); + bool hasError = resp.contains("error"); + expect(hasError, + "undo with no mutation history returns error", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 2: mutate → undo restores previous AST + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + // Rename greet → salute + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "salute"}}); + + // Verify salute exists + bool hasSalute = false; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "salute") + hasSalute = true; + } + + // Undo — should restore greet + json resp = rpc(state, "s1", "undo"); + bool undoOk = resp.contains("result") && + resp["result"].value("success", false); + + bool hasGreet = false; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "greet") + hasGreet = true; + } + + expect(hasSalute && undoOk && hasGreet, + "rename→undo restores original function name", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 3: undo restores editBuf (regenerated code) + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + std::string beforeCode = state.activeBuffer->editBuf; + + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "salute"}}); + + std::string afterCode = state.activeBuffer->editBuf; + + rpc(state, "s1", "undo"); + + std::string undoneCode = state.activeBuffer->editBuf; + + bool codeChanged = afterCode != beforeCode; + bool codeRestored = undoneCode.find("greet") != std::string::npos; + expect(codeChanged && codeRestored, + "undo restores editBuf with original code", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 4: undo → redo restores the mutation + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "salute"}}); + + rpc(state, "s1", "undo"); + + // Now redo + json resp = rpc(state, "s1", "redo"); + bool redoOk = resp.contains("result") && + resp["result"].value("success", false); + + bool hasSalute = false; + if (state.activeAST()) { + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "salute") + hasSalute = true; + } + } + + expect(redoOk && hasSalute, + "undo→redo restores the renamed function", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 5: redo with nothing to redo returns error + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + json resp = rpc(state, "s1", "redo"); + bool hasError = resp.contains("error"); + expect(hasError, + "redo with nothing to redo returns error", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 6: undo/redo report depths + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + // Two mutations + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "salute"}}); + rpc(state, "s1", "renameSymbol", + {{"oldName", "salute"}, {"newName", "welcome"}}); + + // Undo once + json resp1 = rpc(state, "s1", "undo"); + int undoDepth1 = resp1["result"].value("undoDepth", -1); + int redoDepth1 = resp1["result"].value("redoDepth", -1); + + // Undo again + json resp2 = rpc(state, "s1", "undo"); + int undoDepth2 = resp2["result"].value("undoDepth", -1); + int redoDepth2 = resp2["result"].value("redoDepth", -1); + + expect(undoDepth1 == 1 && redoDepth1 == 1 && + undoDepth2 == 0 && redoDepth2 == 2, + "Depths: after 1st undo=" + std::to_string(undoDepth1) + + "/" + std::to_string(redoDepth1) + ", after 2nd undo=" + + std::to_string(undoDepth2) + "/" + + std::to_string(redoDepth2), + passed, failed); + } + + // --------------------------------------------------------------- + // Test 7: Linter cannot undo/redo + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("admin", AgentRole::Refactor); + state.setAgentRole("linter", AgentRole::Linter); + + rpc(state, "admin", "openFile", + {{"path", "t.py"}, {"content", src}}); + + rpc(state, "admin", "renameSymbol", + {{"oldName", "greet"}, {"newName", "salute"}}); + + json undoResp = rpc(state, "linter", "undo"); + json redoResp = rpc(state, "linter", "redo"); + bool undoDenied = undoResp.contains("error"); + bool redoDenied = redoResp.contains("error"); + + expect(undoDenied && redoDenied, + "Linter cannot undo or redo", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 8: multiple mutations → multiple undos + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + // Three renames + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "a"}}); + rpc(state, "s1", "renameSymbol", + {{"oldName", "a"}, {"newName", "b"}}); + rpc(state, "s1", "renameSymbol", + {{"oldName", "b"}, {"newName", "c"}}); + + // Function should be "c" now + bool isC = false; + for (auto* ch : state.activeAST()->allChildren()) { + if (ch->conceptType == "Function" && getNodeName(ch) == "c") + isC = true; + } + + // Undo 3 times — back to greet + rpc(state, "s1", "undo"); + rpc(state, "s1", "undo"); + rpc(state, "s1", "undo"); + + bool isGreet = false; + for (auto* ch : state.activeAST()->allChildren()) { + if (ch->conceptType == "Function" && + getNodeName(ch) == "greet") + isGreet = true; + } + + expect(isC && isGreet, + "3 mutations → 3 undos restores original", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 9: new mutation after undo clears redo stack + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "a"}}); + rpc(state, "s1", "undo"); + + // Redo should be available + int redoBefore = state.activeBuffer->undoStack.redoDepth(); + + // New mutation should clear redo + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "z"}}); + int redoAfter = state.activeBuffer->undoStack.redoDepth(); + + // Redo should now be empty + json redoResp = rpc(state, "s1", "redo"); + bool redoFails = redoResp.contains("error"); + + expect(redoBefore == 1 && redoAfter == 0 && redoFails, + "New mutation after undo clears redo stack", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 10: undo sets modified flag + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + state.activeBuffer->modified = false; + + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "salute"}}); + state.activeBuffer->modified = false; // simulate save + + rpc(state, "s1", "undo"); + bool modAfterUndo = state.activeBuffer->modified; + + expect(modAfterUndo, + "undo sets modified flag on buffer", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 11: MCP tools registered + // --------------------------------------------------------------- + { + MCPServer mcp; + bool foundUndo = false; + bool foundRedo = false; + for (const auto& t : mcp.getTools()) { + if (t.name == "whetstone_undo") foundUndo = true; + if (t.name == "whetstone_redo") foundRedo = true; + } + expect(foundUndo && foundRedo, + "whetstone_undo and whetstone_redo MCP tools registered", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 12: undo/redo with applyMutation (not just rename) + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "t.py"}, {"content", src}}); + + // Get function node ID + std::string fnId; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "greet") { + fnId = c->id; + break; + } + } + + // Mutate via setProperty + rpc(state, "s1", "applyMutation", + {{"type", "setProperty"}, {"nodeId", fnId}, + {"property", "name"}, {"value", "howdy"}}); + + bool isHowdy = false; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "howdy") + isHowdy = true; + } + + // Undo + rpc(state, "s1", "undo"); + + bool isGreetAgain = false; + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function" && + getNodeName(c) == "greet") + isGreetAgain = true; + } + + expect(isHowdy && isGreetAgain, + "applyMutation→undo restores original name", + passed, failed); + } + + std::cout << "\n=== Step 264 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/editor/tests/step265_test.cpp b/editor/tests/step265_test.cpp new file mode 100644 index 0000000..92711b8 --- /dev/null +++ b/editor/tests/step265_test.cpp @@ -0,0 +1,343 @@ +// Step 265 TDD Test: Phase 9e Persistence & Undo/Redo Integration Tests +// +// End-to-end integration tests exercising save + undo/redo workflows: +// save after mutation, undo→save (reverted code on disk), redo→save, +// saveAllBuffers with undo state, MCP tool count, and full workflow. +// +// Follows the Phase closer pattern from Steps 249, 253, 257, 262. +#include "HeadlessEditorState.h" +#include "MCPServer.h" + +#include +#include +#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); +} + +static std::string readFile(const std::string& path) { + std::ifstream in(path); + if (!in) return ""; + return std::string(std::istreambuf_iterator(in), + std::istreambuf_iterator()); +} + +int main() { + int passed = 0; + int failed = 0; + + // Temp workspace for file I/O + auto tmpDir = std::filesystem::temp_directory_path() / + "whetstone_step265_test"; + std::filesystem::create_directories(tmpDir); + + std::string src = + "def greet(name):\n" + " return \"hello \" + name\n"; + + // --------------------------------------------------------------- + // Test 1: mutate → save writes mutated code to disk + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = tmpDir.string(); + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "test1.py"}, {"content", src}}); + + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "salute"}}); + + rpc(state, "s1", "saveBuffer", + {{"path", "test1.py"}}); + + std::string saved = readFile((tmpDir / "test1.py").string()); + bool hasSalute = saved.find("salute") != std::string::npos; + bool noGreet = saved.find("greet") == std::string::npos; + + expect(hasSalute && noGreet, + "mutate→save writes mutated code to disk", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 2: mutate → undo → save writes original code to disk + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = tmpDir.string(); + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "test2.py"}, {"content", src}}); + + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "salute"}}); + rpc(state, "s1", "undo"); + rpc(state, "s1", "saveBuffer", + {{"path", "test2.py"}}); + + std::string saved = readFile((tmpDir / "test2.py").string()); + bool hasGreet = saved.find("greet") != std::string::npos; + bool noSalute = saved.find("salute") == std::string::npos; + + expect(hasGreet && noSalute, + "mutate→undo→save writes original code to disk", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 3: undo → redo → save writes mutated code back + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = tmpDir.string(); + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "test3.py"}, {"content", src}}); + + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "salute"}}); + rpc(state, "s1", "undo"); + rpc(state, "s1", "redo"); + rpc(state, "s1", "saveBuffer", + {{"path", "test3.py"}}); + + std::string saved = readFile((tmpDir / "test3.py").string()); + bool hasSalute = saved.find("salute") != std::string::npos; + + expect(hasSalute, + "undo→redo→save writes mutated code back to disk", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 4: saveAllBuffers after mixed undo states + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = tmpDir.string(); + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "a.py"}, {"content", "def alpha():\n pass\n"}}); + rpc(state, "s1", "openFile", + {{"path", "b.py"}, {"content", "def beta():\n pass\n"}}); + + // Mutate both + rpc(state, "s1", "setActiveBuffer", {{"path", "a.py"}}); + rpc(state, "s1", "renameSymbol", + {{"oldName", "alpha"}, {"newName", "alpha2"}}); + + rpc(state, "s1", "setActiveBuffer", {{"path", "b.py"}}); + rpc(state, "s1", "renameSymbol", + {{"oldName", "beta"}, {"newName", "beta2"}}); + + // Undo on b.py only + rpc(state, "s1", "undo"); + + // Save all + json resp = rpc(state, "s1", "saveAllBuffers"); + int savedCount = resp["result"].value("savedCount", 0); + + std::string savedA = readFile((tmpDir / "a.py").string()); + std::string savedB = readFile((tmpDir / "b.py").string()); + + bool aHasAlpha2 = savedA.find("alpha2") != std::string::npos; + bool bHasBeta = savedB.find("beta") != std::string::npos && + savedB.find("beta2") == std::string::npos; + + expect(savedCount == 2 && aHasAlpha2 && bHasBeta, + "saveAllBuffers: a.py mutated, b.py undone", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 5: multiple undos + save + redo checks consistency + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = tmpDir.string(); + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "test5.py"}, {"content", src}}); + + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "a"}}); + rpc(state, "s1", "renameSymbol", + {{"oldName", "a"}, {"newName", "b"}}); + + // Undo twice back to original + rpc(state, "s1", "undo"); + rpc(state, "s1", "undo"); + + rpc(state, "s1", "saveBuffer", {{"path", "test5.py"}}); + std::string saved1 = readFile((tmpDir / "test5.py").string()); + bool hasGreet = saved1.find("greet") != std::string::npos; + + // Redo once → "a" + rpc(state, "s1", "redo"); + rpc(state, "s1", "saveBuffer", {{"path", "test5.py"}}); + std::string saved2 = readFile((tmpDir / "test5.py").string()); + bool hasA = saved2.find("def a(") != std::string::npos; + + // Redo again → "b" + rpc(state, "s1", "redo"); + rpc(state, "s1", "saveBuffer", {{"path", "test5.py"}}); + std::string saved3 = readFile((tmpDir / "test5.py").string()); + bool hasB = saved3.find("def b(") != std::string::npos; + + expect(hasGreet && hasA && hasB, + "undo/redo/save cycle: greet→a→b all consistent", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 6: MCP tools count includes save and undo/redo + // --------------------------------------------------------------- + { + MCPServer mcp; + auto tools = mcp.getTools(); + int count = (int)tools.size(); + + bool hasSave = false, hasSaveAll = false; + bool hasUndo = false, hasRedo = false; + for (const auto& t : tools) { + if (t.name == "whetstone_save_buffer") hasSave = true; + if (t.name == "whetstone_save_all_buffers") hasSaveAll = true; + if (t.name == "whetstone_undo") hasUndo = true; + if (t.name == "whetstone_redo") hasRedo = true; + } + + expect(count >= 34 && hasSave && hasSaveAll && hasUndo && hasRedo, + "MCP has " + std::to_string(count) + " tools (>=34) with save+undo+redo", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 7: undo after save preserves on-disk version + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = tmpDir.string(); + state.setAgentRole("s1", AgentRole::Refactor); + + rpc(state, "s1", "openFile", + {{"path", "test7.py"}, {"content", src}}); + + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "salute"}}); + rpc(state, "s1", "saveBuffer", {{"path", "test7.py"}}); + + // Disk has "salute" now + std::string onDisk = readFile((tmpDir / "test7.py").string()); + bool diskHasSalute = onDisk.find("salute") != std::string::npos; + + // Undo in memory + rpc(state, "s1", "undo"); + + // Buffer has "greet" but disk still has "salute" + bool bufHasGreet = + state.activeBuffer->editBuf.find("greet") != std::string::npos; + std::string stillOnDisk = readFile((tmpDir / "test7.py").string()); + bool diskStillSalute = stillOnDisk.find("salute") != std::string::npos; + + expect(diskHasSalute && bufHasGreet && diskStillSalute, + "undo in memory doesn't affect saved file on disk", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 8: full workflow: open → mutate → diagnose → undo → redo → save + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.workspaceRoot = tmpDir.string(); + state.setAgentRole("s1", AgentRole::Refactor); + + // 1. Open + rpc(state, "s1", "openFile", + {{"path", "workflow.py"}, {"content", src}}); + bool opened = state.activeBuffer != nullptr; + + // 2. Get AST + json astResp = rpc(state, "s1", "getAST"); + bool gotAST = astResp.contains("result") && + astResp["result"].contains("ast"); + + // 3. Mutate (rename) + rpc(state, "s1", "renameSymbol", + {{"oldName", "greet"}, {"newName", "wave"}}); + bool hasWave = state.activeBuffer->editBuf.find("wave") != + std::string::npos; + + // 4. Get diagnostics + json diagResp = rpc(state, "s1", "getDiagnostics"); + bool gotDiags = diagResp.contains("result"); + + // 5. Undo + json undoResp = rpc(state, "s1", "undo"); + bool undoOk = undoResp.contains("result") && + undoResp["result"].value("success", false); + bool backToGreet = state.activeBuffer->editBuf.find("greet") != + std::string::npos; + + // 6. Redo + json redoResp = rpc(state, "s1", "redo"); + bool redoOk = redoResp.contains("result") && + redoResp["result"].value("success", false); + bool backToWave = state.activeBuffer->editBuf.find("wave") != + std::string::npos; + + // 7. Save + json saveResp = rpc(state, "s1", "saveBuffer", + {{"path", "workflow.py"}}); + bool saveOk = saveResp.contains("result") && + saveResp["result"].value("success", false); + + std::string onDisk = readFile((tmpDir / "workflow.py").string()); + bool diskHasWave = onDisk.find("wave") != std::string::npos; + + expect(opened && gotAST && hasWave && gotDiags && + undoOk && backToGreet && redoOk && backToWave && + saveOk && diskHasWave, + "Full workflow: open→mutate→diagnose→undo→redo→save", + passed, failed); + } + + // Cleanup + std::filesystem::remove_all(tmpDir); + + std::cout << "\n=== Step 265 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index da3ddc4..7516bdd 100644 --- a/progress.md +++ b/progress.md @@ -586,4 +586,99 @@ file opening through cross-file analysis, search, rename, and batch queries. - Phase 9d complete: all 5 steps pass (56/56 tests across steps 258–262) - Full multi-file project workflow validated end-to-end - 30 MCP tools, cross-file symbols, import graph, project diagnostics, search, rename -- Sprint 9 complete: all 4 phases pass (212/212 tests across steps 245–262) +- Sprint 9 phases 9a–9d complete: 212/212 tests across steps 245–262 + +--- + +## Phase 9e: Persistence and Undo/Redo + +### Step 263: Save Buffer to Disk +**Status:** PASS (12/12 tests) + +`saveBuffer` writes a buffer's editBuf to disk, resolving paths against the +workspace root and creating parent directories as needed. `saveAllBuffers` +saves all modified buffers in one call. Both clear the `modified` flag. + +**Files created:** +- `editor/tests/step263_test.cpp` — 12 test cases: write to disk, clear modified + flag, response fields (path/bytesWritten/success), saveAllBuffers batch save, + mutation→save→verify content, Linter permission denied, default to active buffer, + error on unknown buffer, saveAllBuffers clears all flags, returns saved paths, + MCP tool registration, workspace path resolution with parent dir creation + +**Files modified:** +- `editor/src/HeadlessAgentRPCHandler.h` — saveBuffer (resolve path, create + parent dirs, write editBuf, clear modified, return bytesWritten), saveAllBuffers + (iterate bufferStates, save modified, return savedCount/skippedCount/paths); + renameSymbol editBuf regeneration via Pipeline::generate() after AST rename +- `editor/src/AgentPermissionPolicy.h` — saveBuffer/saveAllBuffers/undo/redo + as mutation methods (Refactor/Generator only) +- `editor/src/MCPServer.h` — registerSaveUndoTools() with 4 MCP tools: + whetstone_save_buffer, whetstone_save_all_buffers, whetstone_undo, whetstone_redo +- `editor/CMakeLists.txt` — step263_test target + +**Key design decisions:** +- Paths resolved against workspaceRoot with escape protection +- Parent directories created automatically (std::filesystem::create_directories) +- saveAllBuffers skips unmodified buffers, reports savedCount/skippedCount +- renameSymbol now regenerates editBuf so saved content reflects renamed code +- 34 MCP tools total (was 30): +saveBuffer, +saveAllBuffers, +undo, +redo + +### Step 264: Undo/Redo for Headless Mode +**Status:** PASS (12/12 tests) + +State-based undo/redo using `HeadlessUndoStack`. Every mutation (applyMutation, +applyBatch, renameSymbol) automatically records a snapshot (editBuf + AST JSON). +`undo` restores the previous state; `redo` re-applies after undo. New mutations +after undo clear the redo stack. + +**Files created:** +- `editor/tests/step264_test.cpp` — 12 test cases: no history error, rename→undo + restores original, editBuf restoration, undo→redo restores mutation, redo error, + depth tracking (undoDepth/redoDepth), Linter permission denied, 3 mutations→3 + undos, new mutation clears redo stack, undo sets modified flag, MCP tool + registration, applyMutation→undo + +**Files modified:** +- `editor/src/HeadlessEditorState.h` — HeadlessUndoState (text + astJson), + HeadlessUndoStack class (record, canUndo/canRedo, undo/redo, undoDepth/redoDepth), + HeadlessBufferState gains `undoStack` field, openBuffer records initial state +- `editor/src/HeadlessAgentRPCHandler.h` — undo RPC method (restore AST via + fromJson + sync.setAST, restore editBuf, set modified, return depths), + redo RPC method (same restoration), post-mutation undoStack.record() calls + in applyMutation, applyBatch, renameSymbol handlers + +**Key design decisions:** +- State-based undo (snapshot text + AST JSON) vs operation-based +- Initial state recorded at openFile (position=0), post-mutation at position=N +- Undo decrements position, redo increments — classic position-based stack +- New mutation truncates redo states (standard undo/redo behavior) +- AST restored via JSON roundtrip (toJson/fromJson) for reliable deep clone +- Response includes undoDepth/redoDepth for agent awareness of stack state + +### Step 265: Phase 9e Persistence & Undo/Redo Integration Tests +**Status:** PASS (8/8 tests) + +End-to-end integration tests exercising save + undo/redo workflows together. +Validates that save writes the correct version to disk after mutations, undos, +and redos, and that saveAllBuffers respects mixed undo states across buffers. + +**Files created:** +- `editor/tests/step265_test.cpp` — 8 integration test cases: + 1. mutate→save writes mutated code to disk + 2. mutate→undo→save writes original code to disk + 3. undo→redo→save writes mutated code back to disk + 4. saveAllBuffers with mixed undo states (a.py mutated, b.py undone) + 5. undo/redo/save cycle: greet→a→b all consistent on disk + 6. MCP has 34 tools with save+undo+redo tools present + 7. undo in memory doesn't affect already-saved file on disk + 8. Full workflow: open→mutate→diagnose→undo→redo→save + +**Files modified:** +- `editor/CMakeLists.txt` — step265_test target + +**Key results:** +- Phase 9e complete: all 3 steps pass (32/32 tests across steps 263–265) +- Save + undo/redo integration validated end-to-end +- 34 MCP tools total +- Sprint 9 complete: all 5 phases pass (244/244 tests across steps 245–265)