Step 245: HeadlessEditorState — agent API surface without ImGui/SDL

Adds the headless agent architecture for Sprint 9 Phase 9a:
- ASTUtils.h: pure AST utilities extracted from EditorUtils.h
- HeadlessEditorState.h: GUI-free state with buffer management
- HeadlessAgentRPCHandler.h: full RPC dispatch (20+ methods)
- step245_test.cpp: 20 tests all passing

Also fixes test compilation errors (NotificationSystem API changes,
AgentRole permissions, DependencyPanel missing include) and adds
SDL2 system library detection fix in CMakeLists.txt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bill
2026-02-11 01:34:27 +00:00
parent f63e6a5fdd
commit 9fcb5a0c8c
13 changed files with 1522 additions and 28 deletions

View File

@@ -764,7 +764,13 @@ target_link_libraries(step125_integration_test PRIVATE
unofficial::tree-sitter::tree-sitter
tree_sitter_python
tree_sitter_cpp
tree_sitter_elisp)
tree_sitter_elisp
tree_sitter_javascript
tree_sitter_rust
tree_sitter_go
tree_sitter_typescript
tree_sitter_java
tree_sitter_org)
add_executable(step126_test tests/step126_test.cpp)
target_include_directories(step126_test PRIVATE src)
@@ -780,7 +786,7 @@ target_link_libraries(step128_test PRIVATE nlohmann_json::nlohmann_json)
add_executable(step129_test tests/step129_test.cpp)
target_include_directories(step129_test PRIVATE src)
target_link_libraries(step129_test PRIVATE nlohmann_json::nlohmann_json)
target_link_libraries(step129_test PRIVATE nlohmann_json::nlohmann_json imgui::imgui)
add_executable(step130_test tests/step130_test.cpp)
target_include_directories(step130_test PRIVATE src)
@@ -1249,7 +1255,20 @@ target_link_libraries(step206_test PRIVATE
tree_sitter_rust
tree_sitter_go)
find_package(SDL2 CONFIG REQUIRED)
if(UNIX AND NOT APPLE)
# Force system SDL2 on Linux. The vcpkg toolchain overrides PKG_CONFIG_PATH,
# causing pkg_check_modules to find vcpkg's headless (offscreen-only) SDL2.
# We bypass that by pointing pkg-config directly at the system .pc files.
find_package(PkgConfig REQUIRED)
set(_saved_pkg_config_path "$ENV{PKG_CONFIG_PATH}")
set(ENV{PKG_CONFIG_PATH} "/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig")
pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2)
set(ENV{PKG_CONFIG_PATH} "${_saved_pkg_config_path}")
add_library(SDL2::SDL2 INTERFACE IMPORTED)
target_link_libraries(SDL2::SDL2 INTERFACE PkgConfig::SDL2)
else()
find_package(SDL2 CONFIG REQUIRED)
endif()
find_package(OpenGL REQUIRED)
find_package(glad CONFIG REQUIRED)
find_package(imgui CONFIG REQUIRED)
@@ -1296,7 +1315,7 @@ if(IMGUI_SDL2_BACKEND)
endif()
target_link_libraries(whetstone_editor PRIVATE
SDL2::SDL2
SDL2::SDL2main
$<TARGET_NAME_IF_EXISTS:SDL2::SDL2main>
OpenGL::GL
glad::glad
imgui::imgui
@@ -1335,4 +1354,20 @@ add_executable(whetstone_pipeline src/pipeline_main.cpp)
target_include_directories(whetstone_pipeline PRIVATE src)
target_link_libraries(whetstone_pipeline PRIVATE nlohmann_json::nlohmann_json)
# Step 245: Headless EditorState test (no SDL/ImGui dependency)
add_executable(step245_test tests/step245_test.cpp)
target_include_directories(step245_test PRIVATE src)
target_link_libraries(step245_test PRIVATE
nlohmann_json::nlohmann_json
unofficial::tree-sitter::tree-sitter
tree_sitter_python
tree_sitter_cpp
tree_sitter_elisp
tree_sitter_javascript
tree_sitter_typescript
tree_sitter_java
tree_sitter_rust
tree_sitter_go
tree_sitter_org)
# Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies)

77
editor/src/ASTUtils.h Normal file
View File

@@ -0,0 +1,77 @@
#pragma once
// ASTUtils.h — Pure AST utility functions (no ImGui/SDL dependency).
// Extracted for use by both EditorUtils.h and HeadlessEditorState.h.
#include "ast/ASTNode.h"
#include "ast/Serialization.h"
#include <memory>
#include <string>
// Clone a Module tree (deep copy via JSON roundtrip)
static inline std::unique_ptr<Module> cloneModule(const Module* ast) {
if (!ast) return nullptr;
json j = toJson(ast);
ASTNode* node = fromJson(j);
if (!node || node->conceptType != "Module") return nullptr;
return std::unique_ptr<Module>(static_cast<Module*>(node));
}
// Check if a node is an annotation type
static inline bool isAnnotationNode(const ASTNode* node) {
if (!node) return false;
if (node->conceptType.find("Annotation") != std::string::npos) return true;
if (node->conceptType == "DerefStrategy") return true;
if (node->conceptType == "OptimizationLock") return true;
if (node->conceptType == "LangSpecific") return true;
return false;
}
// Count annotation nodes in an AST subtree
static inline int countAnnotationNodes(const ASTNode* node) {
if (!node) return 0;
int count = isAnnotationNode(node) ? 1 : 0;
for (const auto* child : node->allChildren())
count += countAnnotationNodes(child);
return count;
}
// Find a node by ID in an AST tree
static inline ASTNode* findNodeById(ASTNode* node,
const std::string& id) {
if (!node) return nullptr;
if (node->id == id) return node;
for (auto* child : node->allChildren())
if (auto* found = findNodeById(child, id)) return found;
return nullptr;
}
// Check if a span contains a position
static inline bool spanContains(const ASTNode* node, int line, int col) {
if (!node || !node->hasSpan()) return false;
if (line < node->spanStartLine || line > node->spanEndLine) return false;
if (line == node->spanStartLine && col < node->spanStartCol) return false;
if (line == node->spanEndLine && col > node->spanEndCol) return false;
return true;
}
// Span score (smaller span = better match)
static inline int spanScore(const ASTNode* node) {
if (!node || !node->hasSpan()) return 999999;
return (node->spanEndLine - node->spanStartLine) * 1000 +
(node->spanEndCol - node->spanStartCol);
}
// Find a node at a line/col position
static inline ASTNode* findNodeAtPosition(ASTNode* node,
int line, int col) {
if (!node) return nullptr;
ASTNode* best = nullptr;
if (spanContains(node, line, col)) best = node;
for (auto* child : node->allChildren()) {
ASTNode* cand = findNodeAtPosition(child, line, col);
if (cand) {
if (!best || spanScore(cand) < spanScore(best)) best = cand;
}
}
return best;
}

View File

@@ -8,6 +8,7 @@
#include <fstream>
#include <cstdio>
#include <sstream>
#include <unordered_set>
#include <vector>
#include <string>
#include <unordered_map>

View File

@@ -0,0 +1,558 @@
#pragma once
// Step 245: Headless Agent RPC Handler
//
// Mirrors AgentRPCHandler.h but operates on HeadlessEditorState.
// Uses the same agentRpcError/agentRpcResult helpers and supports
// the same JSON-RPC methods for the agent-critical path:
// getAST, parseSource, generateFromAST, runPipeline, projectLanguage,
// applyMutation, applyBatch, getInScopeSymbols, getCallHierarchy,
// getDependencyGraph, generateCode, setAgentRole,
// getAnnotationSuggestions, applyAnnotationSuggestion,
// recordAnnotationFeedback, startWorkflowRecording,
// stopWorkflowRecording, getWorkflowRecording, replayWorkflow, ping
//
// Deliberately omits GUI-only methods. Stays under 600 lines.
struct HeadlessEditorState;
// Re-use the response helpers (same signature as AgentRPCHandler.h)
static inline json headlessRpcError(const json& id, int code,
const std::string& msg) {
return {{"jsonrpc", "2.0"}, {"id", id},
{"error", {{"code", code}, {"message", msg}}}};
}
static inline json headlessRpcResult(const json& id, const json& result) {
return {{"jsonrpc", "2.0"}, {"id", id}, {"result", result}};
}
static inline json headlessRequireAST(HeadlessEditorState& state,
const json& id) {
if (!state.active() || !state.isStructured())
return headlessRpcError(id, -32000, "No structured buffer");
if (!state.activeAST())
return headlessRpcError(id, -32001, "AST unavailable");
return json();
}
static inline json headlessRequireMutable(HeadlessEditorState& state,
const json& id) {
if (!state.active() || !state.isStructured())
return headlessRpcError(id, -32000, "No structured buffer");
if (!state.mutationAST())
return headlessRpcError(id, -32001, "AST unavailable");
return json();
}
// -----------------------------------------------------------------------
// Main dispatch
// -----------------------------------------------------------------------
inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
const json& request,
const std::string& sessionId) {
json id = request.contains("id") ? request["id"] : json(nullptr);
std::string method = request.value("method", "");
AgentRole role = state.getAgentRole(sessionId);
// --- ping ---
if (method == "ping")
return headlessRpcResult(id, "pong");
// --- getAST ---
if (method == "getAST") {
auto err = headlessRequireAST(state, id);
if (!err.is_null()) return err;
return headlessRpcResult(id, {
{"ast", toJson(state.activeAST())},
{"annotationCount", countAnnotationNodes(state.activeAST())},
{"diagnostics", state.buildDiagnosticsJson()}
});
}
// --- parseSource ---
if (method == "parseSource") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto params = request.contains("params") ? request["params"]
: json::object();
std::string source = params.value("source", "");
std::string language = params.value("language", "");
if (source.empty() || language.empty())
return headlessRpcError(id, -32602,
"Missing source or language");
Pipeline pipeline;
std::vector<ParseDiagnostic> diags;
auto mod = pipeline.parse(source, language, diags);
json diagArr = json::array();
for (const auto& d : diags)
diagArr.push_back({{"line", d.line}, {"column", d.column},
{"message", d.message},
{"severity", d.severity}});
json result = {{"diagnostics", diagArr}};
if (mod) result["ast"] = toJson(mod.get());
return headlessRpcResult(id, result);
}
// --- generateFromAST ---
if (method == "generateFromAST") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto err = headlessRequireAST(state, id);
if (!err.is_null()) return err;
auto params = request.contains("params") ? request["params"]
: json::object();
std::string language = params.value("language",
state.active()->language);
Pipeline pipeline;
std::string code = pipeline.generate(state.activeAST(), language);
return headlessRpcResult(id, {{"code", code},
{"language", language}});
}
// --- runPipeline ---
if (method == "runPipeline") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto params = request.contains("params") ? request["params"]
: json::object();
std::string source = params.value("source", "");
std::string srcLang = params.value("sourceLanguage", "");
std::string tgtLang = params.value("targetLanguage", "");
if (source.empty() || srcLang.empty() || tgtLang.empty())
return headlessRpcError(id, -32602,
"Missing source, sourceLanguage, or targetLanguage");
Pipeline pipeline;
auto pr = pipeline.run(source, srcLang, tgtLang);
json diagArr = json::array();
for (const auto& d : pr.parseDiags)
diagArr.push_back({{"line", d.line}, {"column", d.column},
{"message", d.message},
{"severity", d.severity}});
json valArr = json::array();
for (const auto& d : pr.validationDiags)
valArr.push_back({{"severity", d.severity},
{"message", d.message},
{"nodeId", d.nodeId}});
json violArr = json::array();
for (const auto& v : pr.violations)
violArr.push_back({{"severity", v.severity},
{"category", v.category},
{"message", v.message},
{"nodeId", v.nodeId}});
json suggArr = json::array();
for (const auto& s : pr.suggestions)
suggArr.push_back({{"nodeId", s.nodeId},
{"annotationType", s.annotationType},
{"strategy", s.strategy},
{"reason", s.reason},
{"confidence", s.confidence}});
json result = {
{"success", pr.success}, {"generatedCode", pr.generatedCode},
{"parseDiagnostics", diagArr},
{"validationDiagnostics", valArr},
{"violations", violArr}, {"suggestions", suggArr},
{"foldCount", pr.foldResult.nodesModified},
{"dceCount", pr.dceResult.nodesModified}
};
if (pr.ast) result["ast"] = toJson(pr.ast.get());
return headlessRpcResult(id, result);
}
// --- projectLanguage ---
if (method == "projectLanguage") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto err = headlessRequireAST(state, id);
if (!err.is_null()) return err;
auto params = request.contains("params") ? request["params"]
: json::object();
std::string targetLanguage = params.value("targetLanguage", "");
if (targetLanguage.empty())
return headlessRpcError(id, -32602, "Missing targetLanguage");
CrossLanguageProjector projector;
auto projected = projector.project(state.activeAST(),
targetLanguage);
if (!projected)
return headlessRpcError(id, -32020, "Projection failed");
Pipeline pipeline;
std::string code = pipeline.generate(projected.get(),
targetLanguage);
return headlessRpcResult(id, {
{"ast", toJson(projected.get())}, {"generatedCode", code},
{"sourceLanguage", state.active()->language},
{"targetLanguage", targetLanguage}
});
}
// --- setAgentRole ---
if (method == "setAgentRole") {
auto params = request.contains("params") ? request["params"]
: json::object();
std::string roleName = params.value("role", "linter");
AgentRole newRole =
AgentPermissionPolicy::roleFromString(roleName);
state.setAgentRole(sessionId, newRole);
return headlessRpcResult(id,
{{"role", AgentPermissionPolicy::roleLabel(newRole)}});
}
// --- generateCode ---
if (method == "generateCode") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto err = headlessRequireAST(state, id);
if (!err.is_null()) return err;
auto params = request.contains("params") ? request["params"]
: json::object();
std::string spec = params.value("spec", "");
bool preferImports = params.value("preferImports", true);
state.library.primitives.setRoot(state.activeAST());
state.library.primitives.setLanguage(state.active()->language);
AgentCodeGen gen;
state.library.primitives.setContextTags(
state.library.semanticTags.inferTagsFromText(spec));
AgentCodeGenResult genRes = gen.generate(
spec, state.library.primitives,
state.active()->language, preferImports);
if (!genRes.node)
return headlessRpcError(id, -32020, "Code generation failed");
json nodeJson = toJson(genRes.node);
deleteTree(genRes.node);
return headlessRpcResult(id, {
{"node", nodeJson}, {"note", genRes.note},
{"usedSymbols", genRes.usedSymbols},
{"language", state.active()->language}
});
}
// --- applyMutation ---
if (method == "applyMutation") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
if (!state.active() || !state.isStructured())
return headlessRpcError(id, -32000, "No structured buffer");
auto params = request.contains("params") ? request["params"]
: json::object();
std::string type = params.value("type", "");
bool preferImports = params.value("preferImports", false);
bool strictMode = params.value("strictMode", false);
Module* ast = state.mutationAST();
if (!ast) return headlessRpcError(id, -32001, "AST unavailable");
ASTMutationAPI mut;
mut.setRoot(ast);
ASTMutationAPI::MutationResult res;
LibraryPolicyResult policy;
std::vector<std::string> affectedIds;
if (type == "setProperty") {
std::string nodeId = params.value("nodeId", "");
if (!nodeId.empty()) affectedIds.push_back(nodeId);
res = mut.setProperty(params.value("nodeId", ""),
params.value("property", ""),
params.value("value", ""));
} else if (type == "updateNode") {
std::map<std::string, std::string> props;
if (params.contains("properties")) {
for (auto& [k, v] : params["properties"].items())
props[k] = v.get<std::string>();
}
std::string nodeId = params.value("nodeId", "");
if (!nodeId.empty()) affectedIds.push_back(nodeId);
res = mut.updateNode(params.value("nodeId", ""), props);
} else if (type == "deleteNode") {
std::string nodeId = params.value("nodeId", "");
if (!nodeId.empty()) affectedIds.push_back(nodeId);
res = mut.deleteNode(params.value("nodeId", ""));
} else if (type == "insertNode") {
ASTNode* node = nullptr;
std::string insertedId;
if (params.contains("node")) {
node = fromJson(params["node"]);
if (node) insertedId = node->id;
}
if (node) {
policy = checkMutationLibraryPolicy(
node, ast, preferImports, strictMode);
if (!policy.ok) {
deleteTree(node);
return headlessRpcError(id, -32011, policy.error);
}
}
res = mut.insertNode(params.value("parentId", ""),
params.value("role", ""), node);
if (!res.success && node) {
deleteTree(node);
} else if (res.success && !insertedId.empty()) {
affectedIds.push_back(insertedId);
}
} else {
return headlessRpcError(id, -32602, "Unknown mutation type");
}
if (!res.success)
return headlessRpcError(id, -32010, res.error);
state.applyOrchestratorToActive();
if (state.active()) {
state.active()->incrementalOptimizer.setRoot(
state.active()->sync.getAST());
state.active()->incrementalOptimizer.recordExternalTransform(
"agent-mutation:" + type, affectedIds,
state.agentActorLabel(sessionId));
}
return headlessRpcResult(id, {
{"success", true}, {"warning", res.warning},
{"libraryWarning", policy.warning},
{"unknownFunctions", policy.unknownFunctions}
});
}
// --- applyBatch ---
if (method == "applyBatch") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto errChk = headlessRequireMutable(state, id);
if (!errChk.is_null()) return errChk;
Module* ast = state.mutationAST();
auto params = request.contains("params") ? request["params"]
: json::object();
if (!params.contains("mutations") ||
!params["mutations"].is_array())
return headlessRpcError(id, -32602,
"Missing mutations array");
BatchMutationAPI batch;
batch.setRoot(ast);
std::vector<BatchMutationAPI::Mutation> mutations;
std::vector<ASTNode*> ownedNodes;
for (const auto& m : params["mutations"]) {
BatchMutationAPI::Mutation mut;
mut.type = m.value("type", "");
mut.nodeId = m.value("nodeId", "");
mut.property = m.value("property", "");
mut.value = m.value("value", "");
mut.parentId = m.value("parentId", "");
mut.role = m.value("role", "");
if (m.contains("node")) {
mut.newNode = fromJson(m["node"]);
if (mut.newNode) ownedNodes.push_back(mut.newNode);
}
mutations.push_back(mut);
}
auto batchRes = batch.applySequence(mutations);
if (!batchRes.success) {
for (auto* n : ownedNodes) {
if (n->parent == nullptr) deleteTree(n);
}
return headlessRpcError(id, -32010, batchRes.error);
}
state.applyOrchestratorToActive();
if (state.active()) {
state.active()->incrementalOptimizer.setRoot(
state.active()->sync.getAST());
state.active()->incrementalOptimizer.recordExternalTransform(
"agent-batch", {},
state.agentActorLabel(sessionId));
}
return headlessRpcResult(id,
{{"success", true}, {"appliedCount", batchRes.appliedCount}});
}
// --- getInScopeSymbols ---
if (method == "getInScopeSymbols") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto err = headlessRequireAST(state, id);
if (!err.is_null()) return err;
auto params = request.contains("params") ? request["params"]
: json::object();
std::string nodeId = params.value("nodeId", "");
if (nodeId.empty())
return headlessRpcError(id, -32602,
"Missing nodeId parameter");
ContextAPI ctx;
ctx.setRoot(state.activeAST());
auto symbols = ctx.getInScopeSymbols(nodeId);
json arr = json::array();
for (const auto& s : symbols)
arr.push_back({{"name", s.name}, {"kind", s.kind},
{"nodeId", s.nodeId}});
return headlessRpcResult(id, {{"symbols", arr}});
}
// --- getCallHierarchy ---
if (method == "getCallHierarchy") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto err = headlessRequireAST(state, id);
if (!err.is_null()) return err;
auto params = request.contains("params") ? request["params"]
: json::object();
std::string functionId = params.value("functionId", "");
if (functionId.empty())
return headlessRpcError(id, -32602,
"Missing functionId parameter");
ContextAPI ctx;
ctx.setRoot(state.activeAST());
auto info = ctx.getCallHierarchy(functionId);
return headlessRpcResult(id, {
{"functionId", info.functionId},
{"functionName", info.functionName},
{"callerIds", info.callerIds},
{"calleeIds", info.calleeIds}
});
}
// --- getDependencyGraph ---
if (method == "getDependencyGraph") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto err = headlessRequireAST(state, id);
if (!err.is_null()) return err;
auto params = request.contains("params") ? request["params"]
: json::object();
std::string nodeId = params.value("nodeId", "");
if (nodeId.empty())
return headlessRpcError(id, -32602,
"Missing nodeId parameter");
ContextAPI ctx;
ctx.setRoot(state.activeAST());
auto deps = ctx.getDependencyGraph(nodeId);
return headlessRpcResult(id, {{"dependencies", deps}});
}
// --- getAnnotationSuggestions ---
if (method == "getAnnotationSuggestions") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto err = headlessRequireAST(state, id);
if (!err.is_null()) return err;
Module* ast = state.activeAST();
auto params = request.contains("params") ? request["params"]
: json::object();
std::string nodeId = params.value("nodeId", "");
if (!nodeId.empty()) {
if (!findNodeById(ast, nodeId))
return headlessRpcError(id, -32002,
"Node not found: " + nodeId);
}
AgentAnnotationAssistant assistant;
auto result = assistant.suggest(ast, nodeId);
json suggArr = json::array();
for (const auto& s : result.suggestions)
suggArr.push_back({
{"nodeId", s.nodeId},
{"annotationType", s.annotationType},
{"strategy", s.strategy}, {"reason", s.reason},
{"confidence", s.confidence}
});
json diagArr = json::array();
for (const auto& d : result.diagnostics)
diagArr.push_back({{"severity", d.severity},
{"message", d.message},
{"nodeId", d.nodeId}});
return headlessRpcResult(id, {{"scopeId", result.scopeNodeId},
{"suggestions", suggArr},
{"diagnostics", diagArr}});
}
// --- applyAnnotationSuggestion ---
if (method == "applyAnnotationSuggestion") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto err = headlessRequireMutable(state, id);
if (!err.is_null()) return err;
Module* ast = state.mutationAST();
auto params = request.contains("params") ? request["params"]
: json::object();
MemoryStrategyInference::Suggestion suggestion;
suggestion.nodeId = params.value("nodeId", "");
suggestion.annotationType = params.value("annotationType", "");
suggestion.strategy = params.value("strategy", "");
suggestion.reason = params.value("reason", "");
suggestion.confidence = params.value("confidence", 0.0);
AgentAnnotationAssistant assistant;
auto res = assistant.applySuggestion(ast, suggestion);
if (!res.success)
return headlessRpcError(id, -32010, res.error);
if (params.contains("accepted")) {
bool accepted = params.value("accepted", true);
assistant.recordFeedback(suggestion, accepted);
}
state.applyOrchestratorToActive();
return headlessRpcResult(id,
{{"success", true}, {"warning", res.warning}});
}
// --- recordAnnotationFeedback ---
if (method == "recordAnnotationFeedback") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto params = request.contains("params") ? request["params"]
: json::object();
MemoryStrategyInference::Suggestion suggestion;
suggestion.nodeId = params.value("nodeId", "");
suggestion.annotationType = params.value("annotationType", "");
suggestion.strategy = params.value("strategy", "");
suggestion.reason = params.value("reason", "");
suggestion.confidence = params.value("confidence", 0.0);
bool accepted = params.value("accepted", false);
AgentAnnotationAssistant assistant;
assistant.recordFeedback(suggestion, accepted);
return headlessRpcResult(id, true);
}
// --- startWorkflowRecording ---
if (method == "startWorkflowRecording") {
auto params = request.contains("params") ? request["params"]
: json::object();
std::string name = params.value("name", "workflow");
state.agent.workflowRecorder.startRecording(
name, sessionId, WorkflowRecorder::RecordingConfig{},
state.buildSessionMetadata(false));
return headlessRpcResult(id,
{{"recording", true}, {"name", name}});
}
// --- stopWorkflowRecording ---
if (method == "stopWorkflowRecording")
return headlessRpcResult(id,
state.agent.workflowRecorder.stopRecording());
// --- getWorkflowRecording ---
if (method == "getWorkflowRecording")
return headlessRpcResult(id,
state.agent.workflowRecorder.exportWorkflow());
// --- replayWorkflow ---
if (method == "replayWorkflow") {
auto params = request.contains("params") ? request["params"]
: json::object();
if (!params.contains("workflow"))
return headlessRpcError(id, -32602,
"Missing workflow payload");
WorkflowRecorder temp;
if (!temp.loadWorkflow(params["workflow"]))
return headlessRpcError(id, -32602,
"Invalid workflow payload");
auto requests = temp.buildReplayRequests();
json results = json::array();
state.agent.workflowRecorder.setReplaying(true);
for (auto& req : requests)
results.push_back(
handleHeadlessAgentRequest(state, req, sessionId));
state.agent.workflowRecorder.setReplaying(false);
return headlessRpcResult(id,
{{"count", results.size()}, {"responses", results}});
}
// --- getSessionInfo ---
if (method == "getSessionInfo") {
return headlessRpcResult(id, {
{"mode", "headless"},
{"workspace", state.workspaceRoot},
{"language", state.defaultLanguage},
{"bufferCount", (int)state.bufferStates.size()},
{"activeBuffer", state.active() ? state.active()->path : ""},
{"role", AgentPermissionPolicy::roleLabel(role)}
});
}
return headlessRpcError(id, -32601, "Method not found");
}

View File

@@ -0,0 +1,239 @@
#pragma once
// Step 245: Headless EditorState — agent API surface without ImGui/SDL.
//
// Provides the same method interface that AgentRPCHandler.h expects,
// but with zero GUI dependencies. Uses std::chrono for timestamps,
// stderr for logging, and holds its own lightweight buffer map.
// --- Core AST and pipeline (no ImGui) ---
#include "ast/ASTNode.h"
#include "ast/Serialization.h"
#include "ast/Generator.h"
#include "ast/Annotation.h"
#include "ASTUtils.h"
#include "Pipeline.h"
#include "ASTMutationAPI.h"
#include "BatchMutationAPI.h"
#include "ContextAPI.h"
#include "CrossLanguageProjector.h"
#include "MemoryStrategyInference.h"
#include "AgentAnnotationAssistant.h"
#include "AgentPermissionPolicy.h"
#include "AgentCodeGen.h"
#include "AgentLibraryPolicy.h"
#include "WorkflowRecorder.h"
#include "Orchestrator.h"
#include "IncrementalOptimizer.h"
#include "TextASTSync.h"
#include "BufferManager.h"
#include "EditorModePolicy.h"
#include "PrimitivesRegistry.h"
#include "SemanticTags.h"
#include <nlohmann/json.hpp>
#include <string>
#include <map>
#include <memory>
#include <chrono>
#include <iostream>
using json = nlohmann::json;
// -----------------------------------------------------------------------
// Headless agent state — no WebSocket, no AgentMarketplace (ImGui dep)
// -----------------------------------------------------------------------
struct HeadlessAgentState {
std::map<std::string, AgentRole> roles;
AgentRole defaultRole = AgentRole::Linter;
WorkflowRecorder workflowRecorder;
};
// -----------------------------------------------------------------------
// Headless library state — no ImGui panels
// -----------------------------------------------------------------------
struct HeadlessLibraryState {
PrimitivesRegistry primitives;
SemanticTags semanticTags;
};
// -----------------------------------------------------------------------
// HeadlessBufferState — lightweight buffer without ImGui widgets
// -----------------------------------------------------------------------
struct HeadlessBufferState {
TextASTSync sync;
Orchestrator orchestrator;
IncrementalOptimizer incrementalOptimizer;
std::string language = "python";
std::string path = "(untitled)";
std::string editBuf;
BufferManager::BufferMode bufferMode =
BufferManager::BufferMode::Structured;
bool orchestratorDirty = true;
bool modified = false;
};
// -----------------------------------------------------------------------
// HeadlessEditorState — agent-facing state, no GUI
// -----------------------------------------------------------------------
struct HeadlessEditorState {
std::map<std::string, std::unique_ptr<HeadlessBufferState>>
bufferStates;
HeadlessBufferState* activeBuffer = nullptr;
HeadlessAgentState agent;
HeadlessLibraryState library;
std::string workspaceRoot;
std::string defaultLanguage = "python";
bool verbose = false;
// --- Buffer access ---
HeadlessBufferState* active() { return activeBuffer; }
bool isStructured() const {
return activeBuffer &&
allowStructuredFeatures(activeBuffer->bufferMode);
}
Module* activeAST() {
if (!activeBuffer) return nullptr;
if (!allowStructuredFeatures(activeBuffer->bufferMode))
return nullptr;
return activeBuffer->sync.getAST();
}
Module* mutationAST() {
if (!activeBuffer) return nullptr;
if (!allowStructuredFeatures(activeBuffer->bufferMode))
return nullptr;
if (activeBuffer->orchestratorDirty ||
!activeBuffer->orchestrator.getAST()) {
syncOrchestratorFromActive();
}
return activeBuffer->orchestrator.getAST();
}
void syncOrchestratorFromActive() {
if (!activeBuffer) return;
Module* ast = activeBuffer->sync.getAST();
if (ast) {
activeBuffer->orchestrator.setAST(cloneModule(ast));
activeBuffer->orchestratorDirty = false;
}
}
void applyOrchestratorToActive() {
if (!activeBuffer) return;
if (!isStructured()) return;
Module* ast = activeBuffer->orchestrator.getAST();
if (!ast) return;
activeBuffer->sync.setAST(cloneModule(ast));
activeBuffer->incrementalOptimizer.setRoot(
activeBuffer->sync.getAST());
Pipeline pipeline;
std::string code = pipeline.generate(
activeBuffer->sync.getAST(), activeBuffer->language);
activeBuffer->editBuf = code;
activeBuffer->orchestratorDirty = false;
}
// --- Agent role management ---
AgentRole getAgentRole(const std::string& sessionId) const {
auto it = agent.roles.find(sessionId);
if (it != agent.roles.end()) return it->second;
return agent.defaultRole;
}
void setAgentRole(const std::string& sessionId, AgentRole role) {
agent.roles[sessionId] = role;
}
std::string agentActorLabel(const std::string& sessionId) const {
AgentRole role = getAgentRole(sessionId);
return std::string(AgentPermissionPolicy::roleLabel(role)) +
":" + sessionId;
}
// --- Diagnostics ---
json buildDiagnosticsJson() const {
return json::array();
}
json buildSessionMetadata(bool /*autoRecording*/) const {
return {
{"mode", "headless"},
{"workspace", workspaceRoot},
{"language", defaultLanguage},
{"timestamp",
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count()}
};
}
// --- Buffer operations ---
HeadlessBufferState* openBuffer(const std::string& filePath,
const std::string& content,
const std::string& lang) {
auto buf = std::make_unique<HeadlessBufferState>();
buf->path = filePath;
buf->language = lang.empty() ? defaultLanguage : lang;
buf->editBuf = content;
buf->bufferMode = BufferManager::BufferMode::Structured;
if (!content.empty()) {
Pipeline pipeline;
std::vector<ParseDiagnostic> diags;
auto mod = pipeline.parse(content, buf->language, diags);
if (mod) {
buf->sync.setAST(std::move(mod));
buf->orchestratorDirty = true;
}
} else {
auto mod = std::make_unique<Module>(
"root", filePath, buf->language);
buf->sync.setAST(std::move(mod));
buf->orchestratorDirty = true;
}
HeadlessBufferState* raw = buf.get();
bufferStates[filePath] = std::move(buf);
if (!activeBuffer) activeBuffer = raw;
return raw;
}
bool setActiveBuffer(const std::string& path) {
auto it = bufferStates.find(path);
if (it == bufferStates.end()) return false;
activeBuffer = it->second.get();
return true;
}
void closeBuffer(const std::string& path) {
auto it = bufferStates.find(path);
if (it == bufferStates.end()) return;
if (activeBuffer == it->second.get())
activeBuffer = nullptr;
bufferStates.erase(it);
if (!activeBuffer && !bufferStates.empty())
activeBuffer = bufferStates.begin()->second.get();
}
// --- RPC entry point ---
json processAgentRequest(const json& request,
const std::string& sessionId);
// --- Logging ---
void log(const std::string& msg) const {
if (verbose)
std::cerr << "[whetstone-headless] " << msg << "\n";
}
};
// Include the headless RPC handler and wire up processAgentRequest
#include "HeadlessAgentRPCHandler.h"
inline json HeadlessEditorState::processAgentRequest(
const json& request, const std::string& sessionId) {
return handleHeadlessAgentRequest(*this, request, sessionId);
}

View File

@@ -1,5 +1,6 @@
// Step 125 Integration Test: EditorState agent mutation flow
#include "EditorUtils.h"
#include "AgentPermissionPolicy.h"
#include "imgui.h"
#include <iostream>
@@ -32,7 +33,7 @@ int main() {
state.bufferStates[buf->path] = std::move(buf);
state.activeBuffer = state.bufferStates["(untitled)"].get();
state.agentMutationPermissions["agent_1"] = false;
state.setAgentRole("agent_1", AgentRole::Linter); // Linter cannot mutate
json mutReq = {
{"jsonrpc","2.0"},
{"id",1},
@@ -42,7 +43,7 @@ int main() {
json mutRes = state.processAgentRequest(mutReq, "agent_1");
expect(mutRes.contains("error"), "mutation blocked", passed, failed);
state.agentMutationPermissions["agent_1"] = true;
state.setAgentRole("agent_1", AgentRole::Refactor); // Refactor can mutate
json mutRes2 = state.processAgentRequest(mutReq, "agent_1");
expect(mutRes2.contains("result"), "mutation allowed", passed, failed);
Module* astAfter = state.activeAST();

View File

@@ -1,5 +1,7 @@
// Step 129 TDD Test: Dependency management UI writeback
#include "DependencyPanel.h"
#include "NotificationSystem.h"
#include "VulnerabilityDatabase.h"
#include <iostream>
#include <filesystem>
#include <fstream>
@@ -31,6 +33,9 @@ int main() {
std::filesystem::remove_all(dir);
std::filesystem::create_directories(dir);
NotificationSystem notifications;
VulnerabilityDatabase vulnDb;
// requirements.txt writeback
{
std::ofstream out(dir / "requirements.txt");
@@ -38,8 +43,7 @@ int main() {
out.close();
DependencyPanelState panel;
std::string log;
refreshDependencies(panel, dir.string(), log);
refreshDependencies(panel, dir.string(), notifications, vulnDb);
expect(panel.deps.size() == 1, "requirements parsed", passed, failed);
DependencySpec dep;
@@ -47,7 +51,7 @@ int main() {
dep.version = "2.0.0";
dep.source = "requirements.txt";
panel.deps.push_back(dep);
bool ok = writeDependenciesForSource("requirements.txt", dir.string(), panel.deps, log);
bool ok = writeDependenciesForSource("requirements.txt", dir.string(), panel.deps, notifications);
expect(ok, "requirements writeback ok", passed, failed);
std::string content = readFile(dir / "requirements.txt");
expect(content.find("numpy==1.26.0") != std::string::npos, "requirements keep numpy", passed, failed);
@@ -62,8 +66,7 @@ int main() {
out.close();
DependencyPanelState panel;
std::string log;
refreshDependencies(panel, dir.string(), log);
refreshDependencies(panel, dir.string(), notifications, vulnDb);
for (auto it = panel.deps.begin(); it != panel.deps.end(); ) {
if (it->name == "react") {
@@ -75,7 +78,7 @@ int main() {
++it;
}
}
bool ok = writeDependenciesForSource("package.json", dir.string(), panel.deps, log);
bool ok = writeDependenciesForSource("package.json", dir.string(), panel.deps, notifications);
expect(ok, "package.json writeback ok", passed, failed);
nlohmann::json j;
@@ -90,10 +93,9 @@ int main() {
// edge case: unsupported writeback
{
std::string log;
std::vector<DependencySpec> deps;
deps.push_back({"Boost", "", "CMakeLists.txt"});
bool ok = writeDependenciesForSource("CMakeLists.txt", dir.string(), deps, log);
bool ok = writeDependenciesForSource("CMakeLists.txt", dir.string(), deps, notifications);
expect(!ok, "unsupported writeback returns false", passed, failed);
}

View File

@@ -1,6 +1,7 @@
// Step 142 TDD Test: Emacs package browser
#include "EmacsPackageBrowser.h"
#include "EmacsIntegration.h"
#include "NotificationSystem.h"
#include <iostream>
static void expect(bool cond, const std::string& name, int& passed, int& failed) {
@@ -27,9 +28,9 @@ int main() {
MockEmacsConnection mock;
EmacsPackageBrowserState state;
std::string log;
NotificationSystem notifications;
refreshEmacsPackages(state, mock, log);
refreshEmacsPackages(state, mock, notifications);
expect(!state.packages.empty(), "packages loaded", passed, failed);
const EmacsPackageEntry* usePkg = findPkg(state.packages, "use-package");
@@ -44,7 +45,7 @@ int main() {
expect(magitPkg->status == "available", "available status set", passed, failed);
}
bool loadOk = loadEmacsPackage(mock, "magit", log);
bool loadOk = loadEmacsPackage(mock, "magit", notifications);
expect(loadOk, "load package command", passed, failed);
expect(mock.getLastSentCommand().find("(require 'magit)") != std::string::npos,
"require command sent", passed, failed);

View File

@@ -1,5 +1,6 @@
// Step 143 TDD Test: Elisp function discovery and indexing
#include "EmacsFunctionDiscovery.h"
#include "NotificationSystem.h"
#include <iostream>
static void expect(bool cond, const std::string& name, int& passed, int& failed) {
@@ -17,21 +18,21 @@ int main() {
int failed = 0;
MockEmacsConnection mock;
std::string log;
NotificationSystem notifications;
std::string error;
auto packages = queryEmacsPackageList(mock, false, log, error);
auto packages = queryEmacsPackageList(mock, false, notifications, error);
expect(!packages.empty(), "loaded packages fetched", passed, failed);
auto funcs = queryEmacsFunctions(mock, "^use-package", log);
auto funcs = queryEmacsFunctions(mock, "^use-package", notifications);
expect(!funcs.empty(), "apropos returns functions", passed, failed);
auto doc = queryEmacsFunctionDoc(mock, "use-package", log);
auto doc = queryEmacsFunctionDoc(mock, "use-package", notifications);
expect(!doc.signature.empty(), "describe-function signature", passed, failed);
expect(!doc.doc.empty(), "describe-function docstring", passed, failed);
EmacsFunctionIndex index;
refreshEmacsFunctionIndex(index, mock, packages, log);
refreshEmacsFunctionIndex(index, mock, packages, notifications);
expect(index.functionsByPackage.count("use-package") > 0, "index contains use-package", passed, failed);
Module module("mod1", "test", "elisp");

View File

@@ -1,5 +1,6 @@
// Step 144 TDD Test: Emacs keybinding deep integration
#include "EmacsKeybinding.h"
#include "NotificationSystem.h"
#include <iostream>
static void expect(bool cond, const std::string& name, int& passed, int& failed) {
@@ -18,29 +19,29 @@ int main() {
MockEmacsConnection mock;
EmacsKeybindingState state;
std::string log;
NotificationSystem notifications;
bool prefixOk = emacsHandleKeySequence(state, mock, "C-x", log);
bool prefixOk = emacsHandleKeySequence(state, mock, "C-x", notifications);
expect(prefixOk, "prefix handled", passed, failed);
expect(state.prefix == "C-x", "prefix stored", passed, failed);
bool seqOk = emacsHandleKeySequence(state, mock, "C-s", log);
bool seqOk = emacsHandleKeySequence(state, mock, "C-s", notifications);
expect(seqOk, "sequence handled", passed, failed);
expect(state.prefix.empty(), "prefix cleared", passed, failed);
expect(state.lastCommand == "save-buffer", "command resolved", passed, failed);
expect(mock.getLastSentCommand().find("call-interactively") != std::string::npos,
"call-interactively sent", passed, failed);
bool mxOk = emacsHandleKeySequence(state, mock, "M-x", log);
bool mxOk = emacsHandleKeySequence(state, mock, "M-x", notifications);
expect(mxOk, "M-x handled", passed, failed);
expect(state.minibufferActive, "minibuffer active", passed, failed);
std::snprintf(state.minibufferBuf, sizeof(state.minibufferBuf), "find-file");
bool mbOk = emacsExecuteMinibuffer(state, mock, log);
bool mbOk = emacsExecuteMinibuffer(state, mock, notifications);
expect(mbOk, "minibuffer command executed", passed, failed);
expect(!state.minibufferActive, "minibuffer cleared", passed, failed);
updateEmacsModeLine(state, mock, 2.0, log);
updateEmacsModeLine(state, mock, 2.0, notifications);
expect(!state.modeLine.empty(), "mode line updated", passed, failed);
std::cout << "\n=== Step 144 Results: " << passed << " passed, "

View File

@@ -0,0 +1,126 @@
// Step 245 TDD Test: HeadlessEditorState — agent API without ImGui/SDL
#include "HeadlessEditorState.h"
#include <iostream>
#include <cassert>
static void expect(bool cond, const std::string& name,
int& passed, int& failed) {
if (cond) {
std::cout << "Test " << (passed + failed + 1)
<< " PASS: " << name << "\n";
++passed;
} else {
std::cout << "Test " << (passed + failed + 1)
<< " FAIL: " << name << "\n";
++failed;
}
}
int main() {
int passed = 0;
int failed = 0;
// 1. Construct headless state without crash
HeadlessEditorState state;
state.workspaceRoot = "/tmp/test";
state.defaultLanguage = "python";
expect(state.active() == nullptr, "no active buffer initially",
passed, failed);
// 2. Open a buffer with Python source
std::string src = "def hello(name):\n return 'hi ' + name\n";
auto* buf = state.openBuffer("test.py", src, "python");
expect(buf != nullptr, "buffer opened", passed, failed);
expect(state.active() == buf, "buffer is active", passed, failed);
expect(state.isStructured(), "buffer is structured", passed, failed);
expect(state.activeAST() != nullptr, "AST parsed", passed, failed);
// 3. Parse source via RPC
json parseReq = {
{"jsonrpc", "2.0"}, {"id", 1}, {"method", "parseSource"},
{"params", {{"source", "x = 42"}, {"language", "python"}}}
};
// Set role to Refactor so parseSource is permitted
state.setAgentRole("test-agent", AgentRole::Refactor);
json parseRes = state.processAgentRequest(parseReq, "test-agent");
expect(parseRes.contains("result"), "parseSource returns result",
passed, failed);
expect(parseRes["result"].contains("ast"), "parseSource has AST",
passed, failed);
// 4. Get AST via RPC
json getReq = {
{"jsonrpc", "2.0"}, {"id", 2}, {"method", "getAST"}
};
json getRes = state.processAgentRequest(getReq, "test-agent");
expect(getRes.contains("result"), "getAST returns result",
passed, failed);
expect(getRes["result"].contains("ast"), "getAST has ast field",
passed, failed);
// 5. Run pipeline via RPC
json pipeReq = {
{"jsonrpc", "2.0"}, {"id", 3}, {"method", "runPipeline"},
{"params", {{"source", "def foo(): pass"},
{"sourceLanguage", "python"},
{"targetLanguage", "cpp"}}}
};
json pipeRes = state.processAgentRequest(pipeReq, "test-agent");
expect(pipeRes.contains("result"), "runPipeline returns result",
passed, failed);
expect(pipeRes["result"].contains("generatedCode"),
"pipeline generates code", passed, failed);
// 6. Mutation via RPC — rename the module
Module* ast = state.activeAST();
std::string rootId = ast ? ast->id : "";
expect(!rootId.empty(), "root node has id", passed, failed);
json mutReq = {
{"jsonrpc", "2.0"}, {"id", 4}, {"method", "applyMutation"},
{"params", {{"type", "setProperty"}, {"nodeId", rootId},
{"property", "name"}, {"value", "renamed"}}}
};
json mutRes = state.processAgentRequest(mutReq, "test-agent");
expect(mutRes.contains("result"), "mutation returns result",
passed, failed);
expect(mutRes["result"].value("success", false),
"mutation succeeded", passed, failed);
// 7. Permission check — Linter cannot mutate
state.setAgentRole("linter-agent", AgentRole::Linter);
json mutReq2 = {
{"jsonrpc", "2.0"}, {"id", 5}, {"method", "applyMutation"},
{"params", {{"type", "setProperty"}, {"nodeId", rootId},
{"property", "name"}, {"value", "blocked"}}}
};
json mutRes2 = state.processAgentRequest(mutReq2, "linter-agent");
expect(mutRes2.contains("error"), "linter mutation blocked",
passed, failed);
// 8. Ping
json pingReq = {{"jsonrpc", "2.0"}, {"id", 6}, {"method", "ping"}};
json pingRes = state.processAgentRequest(pingReq, "test-agent");
expect(pingRes.contains("result"), "ping returns result",
passed, failed);
// 9. getSessionInfo
json infoReq = {
{"jsonrpc", "2.0"}, {"id", 7}, {"method", "getSessionInfo"}
};
json infoRes = state.processAgentRequest(infoReq, "test-agent");
expect(infoRes.contains("result"), "getSessionInfo returns result",
passed, failed);
expect(infoRes["result"].value("mode", "") == "headless",
"mode is headless", passed, failed);
// 10. Buffer management — close and reopen
state.closeBuffer("test.py");
expect(state.active() == nullptr, "buffer closed", passed, failed);
expect(!state.isStructured(), "no structured after close",
passed, failed);
std::cout << "\n=== Step 245 Results: " << passed << " passed, "
<< failed << " failed ===\n";
return failed == 0 ? 0 : 1;
}