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

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