Steps 263-265: Phase 9e — save buffer to disk, undo/redo, integration tests

Step 263: saveBuffer/saveAllBuffers RPC methods write editBuf to disk with
workspace path resolution and parent dir creation. 12/12 tests.

Step 264: State-based undo/redo via HeadlessUndoStack. Snapshots recorded
automatically before mutations; undo/redo restore AST + editBuf. 12/12 tests.

Step 265: Phase 9e integration tests — save after undo/redo, mixed undo
states across buffers, full open→mutate→diagnose→undo→redo→save. 8/8 tests.

Phase 9e complete: 32/32 tests. Sprint 9 total: 244/244 across 5 phases.
34 MCP tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bill
2026-02-12 01:27:23 +00:00
parent 872ef493d2
commit c36fd92045
9 changed files with 1536 additions and 4 deletions

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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<RenameChange> 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<Module>(
static_cast<Module*>(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<Module>(
static_cast<Module*>(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"]

View File

@@ -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<HeadlessUndoState> 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;

View File

@@ -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();
}
};

View File

@@ -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 <iostream>
#include <string>
#include <filesystem>
#include <fstream>
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<char>(f)),
std::istreambuf_iterator<char>());
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;
}

View File

@@ -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 <iostream>
#include <string>
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;
}

View File

@@ -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 <iostream>
#include <string>
#include <fstream>
#include <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);
}
static std::string readFile(const std::string& path) {
std::ifstream in(path);
if (!in) return "";
return std::string(std::istreambuf_iterator<char>(in),
std::istreambuf_iterator<char>());
}
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;
}

View File

@@ -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 258262)
- 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 245262)
- Sprint 9 phases 9a9d complete: 212/212 tests across steps 245262
---
## 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 263265)
- Save + undo/redo integration validated end-to-end
- 34 MCP tools total
- Sprint 9 complete: all 5 phases pass (244/244 tests across steps 245265)