Phase 3e complete: Agent API Extend (Steps 59, 61-63)

Step 59: WebSocketAgentServer with session management, JSON-RPC routing, MockWebSocketTransport (10/10 tests)
Step 61: ASTMutationAPI with setProperty, updateNode, deleteNode, insertNode, journal, OptimizationLock warning (6/6 tests)
Step 62: ContextAPI with getInScopeSymbols, getCallHierarchy, getDependencyGraph (6/6 tests)
Step 63: BatchMutationAPI with atomic applySequence and reverse-order rollback (5/5 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bill
2026-02-08 20:57:39 -07:00
parent e92e904a6d
commit 7fb9e89e25
10 changed files with 1400 additions and 109 deletions

View File

@@ -265,6 +265,10 @@ target_include_directories(step57_test PRIVATE src)
add_executable(step58_test tests/step58_test.cpp)
target_include_directories(step58_test PRIVATE src)
add_executable(step59_test tests/step59_test.cpp)
target_include_directories(step59_test PRIVATE src)
target_link_libraries(step59_test PRIVATE nlohmann_json::nlohmann_json)
add_executable(step60_test tests/step60_test.cpp)
target_include_directories(step60_test PRIVATE src)
target_link_libraries(step60_test PRIVATE nlohmann_json::nlohmann_json)

215
editor/src/ASTMutationAPI.h Normal file
View File

@@ -0,0 +1,215 @@
#pragma once
// Step 61: AST mutation API (extend)
//
// ASTMutationAPI: provides validated AST mutations with journal recording
// and OptimizationLock awareness.
//
// Methods:
// setProperty — set a single property on a node
// updateNode — bulk property update
// deleteNode — remove a node from its parent
// insertNode — add a node as child of a parent in a given role
//
// All mutations check for OptimizationLock annotations on the target
// node or its ancestors. If a lock is found the mutation still succeeds
// but a warning is returned (per spec: "warn, don't reject").
#include <string>
#include <vector>
#include <map>
#include "ast/ASTNode.h"
#include "ast/Module.h"
#include "ast/Function.h"
#include "ast/Variable.h"
#include "ast/Parameter.h"
#include "ast/Expression.h"
#include "ast/Annotation.h"
class ASTMutationAPI {
public:
struct MutationResult {
bool success = false;
std::string warning; // non-empty if a warning was produced
std::string error; // non-empty if the mutation failed
};
void setRoot(ASTNode* root) { root_ = root; }
// --- mutations ---
MutationResult setProperty(const std::string& nodeId,
const std::string& property,
const std::string& value) {
MutationResult res;
ASTNode* node = findById(root_, nodeId);
if (!node) {
res.error = "Node not found: " + nodeId;
return res;
}
// Lock check
std::string lockWarning = checkLock(node);
if (!applyProperty(node, property, value)) {
res.error = "Cannot set property '" + property +
"' on " + node->conceptType;
return res;
}
// Record in journal
journal_.push_back("setProperty " + nodeId + "." + property +
" = " + value);
res.success = true;
res.warning = lockWarning;
return res;
}
MutationResult updateNode(const std::string& nodeId,
const std::map<std::string, std::string>& properties) {
MutationResult res;
ASTNode* node = findById(root_, nodeId);
if (!node) {
res.error = "Node not found: " + nodeId;
return res;
}
std::string lockWarning = checkLock(node);
for (const auto& [prop, val] : properties) {
if (!applyProperty(node, prop, val)) {
res.error = "Cannot set property '" + prop +
"' on " + node->conceptType;
return res;
}
}
journal_.push_back("updateNode " + nodeId + " (" +
std::to_string(properties.size()) + " properties)");
res.success = true;
res.warning = lockWarning;
return res;
}
MutationResult deleteNode(const std::string& nodeId) {
MutationResult res;
ASTNode* node = findById(root_, nodeId);
if (!node) {
res.error = "Node not found: " + nodeId;
return res;
}
if (!node->parent) {
res.error = "Cannot delete root node";
return res;
}
std::string lockWarning = checkLock(node);
ASTNode* par = node->parent;
if (!par->removeChild(node)) {
res.error = "Failed to remove node from parent";
return res;
}
journal_.push_back("deleteNode " + nodeId);
res.success = true;
res.warning = lockWarning;
return res;
}
MutationResult insertNode(const std::string& parentId,
const std::string& role,
ASTNode* node) {
MutationResult res;
ASTNode* parent = findById(root_, parentId);
if (!parent) {
res.error = "Parent not found: " + parentId;
return res;
}
if (!node) {
res.error = "Node is null";
return res;
}
std::string lockWarning = checkLock(parent);
parent->addChild(role, node);
journal_.push_back("insertNode " + node->id + " into " +
parentId + "." + role);
res.success = true;
res.warning = lockWarning;
return res;
}
// --- journal ---
std::vector<std::string> getJournalEntries() const { return journal_; }
void clearJournal() { journal_.clear(); }
private:
// Recursive node lookup
ASTNode* findById(ASTNode* node, const std::string& nodeId) const {
if (!node) return nullptr;
if (node->id == nodeId) return node;
for (auto* child : node->allChildren()) {
auto* found = findById(child, nodeId);
if (found) return found;
}
return nullptr;
}
// Walk up the ancestor chain looking for an OptimizationLock annotation.
// Returns a warning string if locked, empty string otherwise.
std::string checkLock(const ASTNode* node) const {
const ASTNode* cur = node;
while (cur) {
for (const auto* anno : cur->getChildren("annotations")) {
if (anno->conceptType == "OptimizationLock") {
auto* lock = static_cast<const OptimizationLock*>(anno);
return "Node is under OptimizationLock (locked by " +
lock->lockedBy + ": " + lock->lockReason + ")";
}
}
cur = cur->parent;
}
return "";
}
// Apply a string property to a node. Returns false if the property
// is not recognised for the node's concept type.
bool applyProperty(ASTNode* node, const std::string& property,
const std::string& value) const {
const auto& ct = node->conceptType;
if (property == "name") {
if (ct == "Function") { static_cast<Function*>(node)->name = value; return true; }
if (ct == "Variable") { static_cast<Variable*>(node)->name = value; return true; }
if (ct == "Parameter") { static_cast<Parameter*>(node)->name = value; return true; }
if (ct == "Module") { static_cast<Module*>(node)->name = value; return true; }
}
if (property == "strategy") {
if (ct == "DerefStrategy") { static_cast<DerefStrategy*>(node)->strategy = value; return true; }
if (ct == "DeallocateAnnotation") { static_cast<DeallocateAnnotation*>(node)->strategy = value; return true; }
if (ct == "LifetimeAnnotation") { static_cast<LifetimeAnnotation*>(node)->strategy = value; return true; }
if (ct == "ReclaimAnnotation") { static_cast<ReclaimAnnotation*>(node)->strategy = value; return true; }
if (ct == "OwnerAnnotation") { static_cast<OwnerAnnotation*>(node)->strategy = value; return true; }
if (ct == "AllocateAnnotation") { static_cast<AllocateAnnotation*>(node)->strategy = value; return true; }
}
if (property == "op") {
if (ct == "BinaryOperation") { static_cast<BinaryOperation*>(node)->op = value; return true; }
}
if (property == "variableName") {
if (ct == "VariableReference") { static_cast<VariableReference*>(node)->variableName = value; return true; }
}
if (property == "targetLanguage") {
if (ct == "Module") { static_cast<Module*>(node)->targetLanguage = value; return true; }
}
return false;
}
ASTNode* root_ = nullptr;
std::vector<std::string> journal_;
};

View File

@@ -0,0 +1,237 @@
#pragma once
// Step 63: Batch operations (extend)
//
// BatchMutationAPI: atomic all-or-nothing mutation sequences.
//
// applySequence(mutations) tries each mutation in order. If any
// mutation fails, every preceding mutation is rolled back in reverse
// order so the AST returns to its pre-batch state.
//
// Supported mutation types: "setProperty", "insertNode", "deleteNode".
#include <string>
#include <vector>
#include <map>
#include <functional>
#include <algorithm>
#include "ast/ASTNode.h"
#include "ast/Module.h"
#include "ast/Function.h"
#include "ast/Variable.h"
#include "ast/Parameter.h"
#include "ast/Expression.h"
class BatchMutationAPI {
public:
struct Mutation {
std::string type; // "setProperty", "insertNode", "deleteNode"
std::string nodeId;
std::string property; // for setProperty
std::string value; // for setProperty
std::string parentId; // for insertNode
std::string role; // for insertNode
ASTNode* newNode = nullptr; // for insertNode
};
struct BatchResult {
bool success = false;
int appliedCount = 0;
std::string error;
};
void setRoot(ASTNode* root) { root_ = root; }
BatchResult applySequence(const std::vector<Mutation>& mutations) {
BatchResult res;
std::vector<UndoAction> undoStack;
for (size_t i = 0; i < mutations.size(); ++i) {
const auto& mut = mutations[i];
std::string err;
if (mut.type == "setProperty") {
err = applySetProperty(mut, undoStack);
} else if (mut.type == "insertNode") {
err = applyInsertNode(mut, undoStack);
} else if (mut.type == "deleteNode") {
err = applyDeleteNode(mut, undoStack);
} else {
err = "Unknown mutation type: " + mut.type;
}
if (!err.empty()) {
// Roll back everything applied so far
rollback(undoStack);
res.success = false;
res.appliedCount = static_cast<int>(i);
res.error = err;
return res;
}
}
// All succeeded — record in journal
for (const auto& undo : undoStack) {
journal_.push_back(undo.description);
}
res.success = true;
res.appliedCount = static_cast<int>(mutations.size());
return res;
}
std::vector<std::string> getJournalEntries() const { return journal_; }
void clearJournal() { journal_.clear(); }
private:
// An undo action captures how to reverse a single mutation.
struct UndoAction {
std::function<void()> undo;
std::string description;
};
// --- mutation implementations ---
std::string applySetProperty(const Mutation& mut,
std::vector<UndoAction>& undoStack) {
ASTNode* node = findById(root_, mut.nodeId);
if (!node)
return "Node not found: " + mut.nodeId;
// Read old value
std::string oldValue = getStringProperty(node, mut.property);
if (!setStringProperty(node, mut.property, mut.value))
return "Cannot set property '" + mut.property +
"' on " + node->conceptType;
// Capture undo
std::string nid = mut.nodeId;
std::string prop = mut.property;
ASTNode** rootPtr = &root_;
undoStack.push_back({
[node, prop, oldValue, this]() {
setStringProperty(node, prop, oldValue);
},
"setProperty " + mut.nodeId + "." + mut.property + " = " + mut.value
});
return "";
}
std::string applyInsertNode(const Mutation& mut,
std::vector<UndoAction>& undoStack) {
ASTNode* parent = findById(root_, mut.parentId);
if (!parent)
return "Parent not found: " + mut.parentId;
if (!mut.newNode)
return "Node is null for insertNode";
parent->addChild(mut.role, mut.newNode);
ASTNode* inserted = mut.newNode;
undoStack.push_back({
[parent, inserted]() {
parent->removeChild(inserted);
},
"insertNode " + inserted->id + " into " + mut.parentId + "." + mut.role
});
return "";
}
std::string applyDeleteNode(const Mutation& mut,
std::vector<UndoAction>& undoStack) {
ASTNode* node = findById(root_, mut.nodeId);
if (!node)
return "Node not found: " + mut.nodeId;
if (!node->parent)
return "Cannot delete root node";
ASTNode* parent = node->parent;
std::string role = findRole(parent, node);
if (role.empty())
return "Could not determine role for node " + mut.nodeId;
if (!parent->removeChild(node))
return "Failed to remove node from parent";
undoStack.push_back({
[parent, node, role]() {
parent->addChild(role, node);
},
"deleteNode " + mut.nodeId
});
return "";
}
// --- rollback ---
static void rollback(std::vector<UndoAction>& undoStack) {
for (auto it = undoStack.rbegin(); it != undoStack.rend(); ++it) {
it->undo();
}
undoStack.clear();
}
// --- helpers ---
ASTNode* findById(ASTNode* node, const std::string& id) const {
if (!node) return nullptr;
if (node->id == id) return node;
for (auto* child : node->allChildren()) {
auto* found = findById(child, id);
if (found) return found;
}
return nullptr;
}
// Find which role a child occupies in its parent
static std::string findRole(const ASTNode* parent, const ASTNode* child) {
for (const auto& role : parent->childRoles()) {
for (const auto* kid : parent->getChildren(role)) {
if (kid == child) return role;
}
}
return "";
}
static std::string getStringProperty(const ASTNode* node,
const std::string& prop) {
const auto& ct = node->conceptType;
if (prop == "name") {
if (ct == "Function") return static_cast<const Function*>(node)->name;
if (ct == "Variable") return static_cast<const Variable*>(node)->name;
if (ct == "Parameter") return static_cast<const Parameter*>(node)->name;
if (ct == "Module") return static_cast<const Module*>(node)->name;
}
if (prop == "op" && ct == "BinaryOperation")
return static_cast<const BinaryOperation*>(node)->op;
if (prop == "variableName" && ct == "VariableReference")
return static_cast<const VariableReference*>(node)->variableName;
if (prop == "targetLanguage" && ct == "Module")
return static_cast<const Module*>(node)->targetLanguage;
return "";
}
bool setStringProperty(ASTNode* node, const std::string& prop,
const std::string& value) const {
const auto& ct = node->conceptType;
if (prop == "name") {
if (ct == "Function") { static_cast<Function*>(node)->name = value; return true; }
if (ct == "Variable") { static_cast<Variable*>(node)->name = value; return true; }
if (ct == "Parameter") { static_cast<Parameter*>(node)->name = value; return true; }
if (ct == "Module") { static_cast<Module*>(node)->name = value; return true; }
}
if (prop == "op" && ct == "BinaryOperation") {
static_cast<BinaryOperation*>(node)->op = value; return true;
}
if (prop == "variableName" && ct == "VariableReference") {
static_cast<VariableReference*>(node)->variableName = value; return true;
}
if (prop == "targetLanguage" && ct == "Module") {
static_cast<Module*>(node)->targetLanguage = value; return true;
}
return false;
}
ASTNode* root_ = nullptr;
std::vector<std::string> journal_;
};

261
editor/src/ContextAPI.h Normal file
View File

@@ -0,0 +1,261 @@
#pragma once
// Step 62: Context API
//
// ContextAPI: provides scope, call-hierarchy, and data-flow queries
// over the SemAnno AST.
//
// Methods:
// getInScopeSymbols — variables/functions/parameters visible at a node
// getCallHierarchy — callers and callees of a function
// getDependencyGraph — data-flow dependencies of a node (via VariableRef)
#include <string>
#include <vector>
#include <algorithm>
#include "ast/ASTNode.h"
#include "ast/Module.h"
#include "ast/Function.h"
#include "ast/Variable.h"
#include "ast/Parameter.h"
#include "ast/Expression.h"
class ContextAPI {
public:
struct Symbol {
std::string name;
std::string kind; // "function", "variable", "parameter"
std::string nodeId;
};
struct CallInfo {
std::string functionId;
std::string functionName;
std::vector<std::string> callerIds; // functions that call this one
std::vector<std::string> calleeIds; // functions called by this one
};
void setRoot(ASTNode* root) { root_ = root; }
// ---------------------------------------------------------------
// getInScopeSymbols — walk up from nodeId, collecting symbols
// ---------------------------------------------------------------
std::vector<Symbol> getInScopeSymbols(const std::string& nodeId) const {
ASTNode* node = findById(root_, nodeId);
if (!node) return {};
std::vector<Symbol> symbols;
// Walk up the ancestor chain (including node itself)
const ASTNode* cur = node;
while (cur) {
if (cur->conceptType == "Function") {
auto* fn = static_cast<const Function*>(cur);
// Parameters
for (auto* p : fn->getChildren("parameters")) {
if (p->conceptType == "Parameter") {
auto* param = static_cast<const Parameter*>(p);
addSymbolUnique(symbols, {param->name, "parameter", param->id});
}
}
// Body variables
for (auto* child : fn->getChildren("body")) {
if (child->conceptType == "Variable") {
auto* v = static_cast<const Variable*>(child);
addSymbolUnique(symbols, {v->name, "variable", v->id});
}
}
}
else if (cur->conceptType == "Module") {
auto* mod = static_cast<const Module*>(cur);
// Module-level variables
for (auto* child : mod->getChildren("variables")) {
if (child->conceptType == "Variable") {
auto* v = static_cast<const Variable*>(child);
addSymbolUnique(symbols, {v->name, "variable", v->id});
}
}
// Module-level functions
for (auto* child : mod->getChildren("functions")) {
if (child->conceptType == "Function") {
auto* fn = static_cast<const Function*>(child);
addSymbolUnique(symbols, {fn->name, "function", fn->id});
}
}
}
cur = cur->parent;
}
return symbols;
}
// ---------------------------------------------------------------
// getCallHierarchy
// ---------------------------------------------------------------
CallInfo getCallHierarchy(const std::string& functionId) const {
CallInfo info;
ASTNode* fnNode = findById(root_, functionId);
if (!fnNode || fnNode->conceptType != "Function") return info;
auto* fn = static_cast<Function*>(fnNode);
info.functionId = fn->id;
info.functionName = fn->name;
// Collect ALL FunctionCall nodes in the entire tree
std::vector<ASTNode*> allCalls;
collectByType(root_, "FunctionCall", allCalls);
// callerIds: functions that contain a call to fn->name
// calleeIds: functions called from within fn
for (auto* callNode : allCalls) {
auto* fc = static_cast<FunctionCall*>(callNode);
if (fc->functionName == fn->name) {
// Someone calls our function — find the enclosing Function
Function* caller = enclosingFunction(callNode);
if (caller && caller->id != fn->id) {
addUnique(info.callerIds, caller->id);
}
}
// Is this call inside our function?
if (isDescendantOf(callNode, fnNode)) {
// Resolve callee function ID by name
std::string calleeId = resolveFunctionId(fc->functionName);
if (!calleeId.empty()) {
addUnique(info.calleeIds, calleeId);
}
}
}
return info;
}
// ---------------------------------------------------------------
// getDependencyGraph — data-flow deps via VariableReference
// ---------------------------------------------------------------
std::vector<std::string> getDependencyGraph(const std::string& nodeId) const {
ASTNode* node = findById(root_, nodeId);
if (!node) return {};
// Collect all VariableReference nodes in the subtree
std::vector<ASTNode*> refs;
collectByType(node, "VariableReference", refs);
std::vector<std::string> deps;
for (auto* refNode : refs) {
auto* vr = static_cast<VariableReference*>(refNode);
// Resolve to the declaration node (Variable or Parameter)
std::string declId = resolveSymbolId(vr->variableName, refNode);
if (!declId.empty()) {
addUnique(deps, declId);
}
}
return deps;
}
private:
// --- helpers ---
ASTNode* findById(ASTNode* node, const std::string& id) const {
if (!node) return nullptr;
if (node->id == id) return node;
for (auto* child : node->allChildren()) {
auto* found = findById(child, id);
if (found) return found;
}
return nullptr;
}
static void collectByType(ASTNode* node, const std::string& type,
std::vector<ASTNode*>& out) {
if (!node) return;
if (node->conceptType == type) out.push_back(node);
for (auto* child : node->allChildren()) {
collectByType(child, type, out);
}
}
// Walk up to find the nearest enclosing Function
static Function* enclosingFunction(ASTNode* node) {
ASTNode* cur = node->parent;
while (cur) {
if (cur->conceptType == "Function")
return static_cast<Function*>(cur);
cur = cur->parent;
}
return nullptr;
}
// Check if child is a descendant of ancestor
static bool isDescendantOf(const ASTNode* child, const ASTNode* ancestor) {
const ASTNode* cur = child->parent;
while (cur) {
if (cur == ancestor) return true;
cur = cur->parent;
}
return false;
}
// Resolve a function name to its node ID (searching module-level functions)
std::string resolveFunctionId(const std::string& name) const {
if (!root_) return "";
// Find the module (root or first module child)
const ASTNode* mod = root_;
if (mod->conceptType != "Module") return "";
for (auto* child : mod->getChildren("functions")) {
if (child->conceptType == "Function") {
auto* fn = static_cast<const Function*>(child);
if (fn->name == name) return fn->id;
}
}
return "";
}
// Resolve a variable/parameter name to its declaration ID,
// searching up the scope chain from refNode.
std::string resolveSymbolId(const std::string& name,
const ASTNode* refNode) const {
const ASTNode* cur = refNode;
while (cur) {
if (cur->conceptType == "Function") {
auto* fn = static_cast<const Function*>(cur);
for (auto* p : fn->getChildren("parameters")) {
if (p->conceptType == "Parameter" &&
static_cast<const Parameter*>(p)->name == name)
return p->id;
}
for (auto* child : fn->getChildren("body")) {
if (child->conceptType == "Variable" &&
static_cast<const Variable*>(child)->name == name)
return child->id;
}
}
else if (cur->conceptType == "Module") {
for (auto* child : cur->getChildren("variables")) {
if (child->conceptType == "Variable" &&
static_cast<const Variable*>(child)->name == name)
return child->id;
}
}
cur = cur->parent;
}
return "";
}
static void addSymbolUnique(std::vector<Symbol>& symbols,
const Symbol& sym) {
for (const auto& s : symbols) {
if (s.nodeId == sym.nodeId) return;
}
symbols.push_back(sym);
}
static void addUnique(std::vector<std::string>& vec,
const std::string& val) {
if (std::find(vec.begin(), vec.end(), val) == vec.end())
vec.push_back(val);
}
ASTNode* root_ = nullptr;
};

View File

@@ -0,0 +1,359 @@
#pragma once
// Step 59: WebSocket agent endpoint
//
// WebSocketAgentServer: manages agent sessions and routes JSON-RPC messages.
// Agents connect via WebSocket and exchange JSON-RPC over the connection.
//
// Architecture:
// WebSocketTransport (abstract) — handles raw WebSocket I/O
// WebSocketAgentServer — session management + JSON-RPC dispatch
// MockWebSocketTransport — for testing without real sockets
//
// The server runs alongside the existing stdin/stdout JSON-RPC in
// orchestrator_main.cpp. Both share the same processRequest() handler
// via the setRequestHandler() callback.
#include <string>
#include <vector>
#include <map>
#include <functional>
#include <memory>
#include <chrono>
#include <nlohmann/json.hpp>
using json = nlohmann::json;
// ---------------------------------------------------------------------------
// AgentSession — represents one connected agent
// ---------------------------------------------------------------------------
struct AgentSession {
std::string sessionId;
std::string agentName; // optional, set via setAgentName RPC
bool connected = false;
uint64_t connectedAtMs = 0;
uint64_t lastMessageAtMs = 0;
int messageCount = 0;
};
// Callback types
using JsonRpcHandler = std::function<json(const json& request)>;
using SessionEventCallback =
std::function<void(const AgentSession& session, const std::string& event)>;
// ---------------------------------------------------------------------------
// WebSocketTransport — abstract transport interface
// ---------------------------------------------------------------------------
class WebSocketTransport {
public:
virtual ~WebSocketTransport() = default;
virtual bool start(int port) = 0;
virtual void stop() = 0;
virtual bool isRunning() const = 0;
// Send a text message to a connected session
virtual bool sendMessage(const std::string& sessionId,
const std::string& message) = 0;
// --- handler registration (called by WebSocketAgentServer) ---
void setMessageHandler(
std::function<void(const std::string& sessionId,
const std::string& message)> handler) {
messageHandler_ = std::move(handler);
}
void setConnectHandler(
std::function<void(const std::string& sessionId)> handler) {
connectHandler_ = std::move(handler);
}
void setDisconnectHandler(
std::function<void(const std::string& sessionId)> handler) {
disconnectHandler_ = std::move(handler);
}
protected:
std::function<void(const std::string&, const std::string&)> messageHandler_;
std::function<void(const std::string&)> connectHandler_;
std::function<void(const std::string&)> disconnectHandler_;
};
// ---------------------------------------------------------------------------
// WebSocketAgentServer
// ---------------------------------------------------------------------------
class WebSocketAgentServer {
public:
explicit WebSocketAgentServer(std::unique_ptr<WebSocketTransport> transport)
: transport_(std::move(transport))
{
transport_->setMessageHandler(
[this](const std::string& sid, const std::string& msg) {
onMessage(sid, msg);
});
transport_->setConnectHandler(
[this](const std::string& sid) { onConnect(sid); });
transport_->setDisconnectHandler(
[this](const std::string& sid) { onDisconnect(sid); });
}
bool start(int port) {
port_ = port;
return transport_->start(port);
}
void stop() { transport_->stop(); }
bool isRunning() const { return transport_->isRunning(); }
int getPort() const { return port_; }
// Register a fallback JSON-RPC handler for methods the server doesn't
// handle itself (e.g. the orchestrator's processRequest).
void setRequestHandler(JsonRpcHandler handler) {
requestHandler_ = std::move(handler);
}
// Register a callback for session lifecycle events ("connected" /
// "disconnected").
void setSessionEventCallback(SessionEventCallback cb) {
sessionEventCallback_ = std::move(cb);
}
// --- session queries ---
const AgentSession* getSession(const std::string& sessionId) const {
auto it = sessions_.find(sessionId);
return it != sessions_.end() ? &it->second : nullptr;
}
std::vector<AgentSession> getActiveSessions() const {
std::vector<AgentSession> result;
for (const auto& [id, s] : sessions_) {
if (s.connected) result.push_back(s);
}
return result;
}
size_t getActiveSessionCount() const {
size_t n = 0;
for (const auto& [id, s] : sessions_) {
if (s.connected) ++n;
}
return n;
}
private:
// --- transport callbacks ---
void onConnect(const std::string& sessionId) {
AgentSession s;
s.sessionId = sessionId;
s.connected = true;
s.connectedAtMs = nowMs();
s.lastMessageAtMs = s.connectedAtMs;
sessions_[sessionId] = s;
if (sessionEventCallback_)
sessionEventCallback_(s, "connected");
}
void onDisconnect(const std::string& sessionId) {
auto it = sessions_.find(sessionId);
if (it != sessions_.end()) {
it->second.connected = false;
if (sessionEventCallback_)
sessionEventCallback_(it->second, "disconnected");
}
}
void onMessage(const std::string& sessionId, const std::string& message) {
// Update session stats
auto it = sessions_.find(sessionId);
if (it != sessions_.end()) {
it->second.lastMessageAtMs = nowMs();
it->second.messageCount++;
}
// Parse + dispatch
json response;
try {
json request = json::parse(message);
response = processRequest(request, sessionId);
} catch (const std::exception& e) {
response = {
{"jsonrpc", "2.0"},
{"id", nullptr},
{"error", {{"code", -32700},
{"message", std::string("Parse error: ") + e.what()}}}
};
}
transport_->sendMessage(sessionId, response.dump());
}
// Route a JSON-RPC request. Built-in methods are handled first;
// everything else is forwarded to the registered handler.
json processRequest(const json& request, const std::string& sessionId) {
json response;
response["jsonrpc"] = "2.0";
response["id"] = request.contains("id") ? request["id"] : json(nullptr);
try {
std::string method = request.at("method").get<std::string>();
if (method == "ping") {
response["result"] = json{
{"message", "pong"},
{"sessionId", sessionId}
};
}
else if (method == "getSessionInfo") {
auto* sess = getSession(sessionId);
if (sess) {
response["result"] = {
{"sessionId", sess->sessionId},
{"agentName", sess->agentName},
{"connected", sess->connected},
{"messageCount", sess->messageCount}
};
} else {
response["error"] = {
{"code", -32603},
{"message", "Session not found"}
};
}
}
else if (method == "setAgentName") {
std::string name =
request.at("params").at("name").get<std::string>();
auto sit = sessions_.find(sessionId);
if (sit != sessions_.end()) {
sit->second.agentName = name;
response["result"] = true;
} else {
response["error"] = {
{"code", -32603},
{"message", "Session not found"}
};
}
}
else if (method == "listSessions") {
json arr = json::array();
for (const auto& [id, s] : sessions_) {
if (s.connected) {
arr.push_back({
{"sessionId", s.sessionId},
{"agentName", s.agentName},
{"messageCount", s.messageCount}
});
}
}
response["result"] = arr;
}
else if (requestHandler_) {
// Delegate to the orchestrator's processRequest
response = requestHandler_(request);
// Ensure jsonrpc and id are set
response["jsonrpc"] = "2.0";
if (!response.contains("id"))
response["id"] = request.contains("id")
? request["id"] : json(nullptr);
}
else {
response["error"] = {
{"code", -32601},
{"message", "Method not found: " + method}
};
}
} catch (const std::exception& e) {
response["error"] = {
{"code", -32600},
{"message", std::string("Invalid request: ") + e.what()}
};
}
return response;
}
static uint64_t nowMs() {
using namespace std::chrono;
return static_cast<uint64_t>(
duration_cast<milliseconds>(
steady_clock::now().time_since_epoch())
.count());
}
std::unique_ptr<WebSocketTransport> transport_;
std::map<std::string, AgentSession> sessions_;
JsonRpcHandler requestHandler_;
SessionEventCallback sessionEventCallback_;
int port_ = 0;
};
// ---------------------------------------------------------------------------
// MockWebSocketTransport — for testing without real sockets
// ---------------------------------------------------------------------------
class MockWebSocketTransport : public WebSocketTransport {
public:
bool start(int port) override {
port_ = port;
running_ = true;
return true;
}
void stop() override { running_ = false; }
bool isRunning() const override { return running_; }
bool sendMessage(const std::string& sessionId,
const std::string& message) override {
sentMessages_[sessionId].push_back(message);
return true;
}
// --- test helpers: simulate agent behaviour ---
// Simulate an agent opening a WebSocket connection
std::string simulateConnect() {
std::string sessionId = "agent_" + std::to_string(nextId_++);
if (connectHandler_) connectHandler_(sessionId);
return sessionId;
}
// Simulate an agent closing its connection
void simulateDisconnect(const std::string& sessionId) {
if (disconnectHandler_) disconnectHandler_(sessionId);
}
// Simulate an agent sending a text message
void simulateMessage(const std::string& sessionId,
const std::string& message) {
if (messageHandler_) messageHandler_(sessionId, message);
}
// Get all messages the server sent back to a session
std::vector<std::string> getSentMessages(
const std::string& sessionId) const {
auto it = sentMessages_.find(sessionId);
return it != sentMessages_.end()
? it->second
: std::vector<std::string>{};
}
// Get the most recent message sent to a session
std::string getLastSentMessage(const std::string& sessionId) const {
auto it = sentMessages_.find(sessionId);
if (it != sentMessages_.end() && !it->second.empty())
return it->second.back();
return "";
}
// Clear recorded messages (useful between test cases)
void clearSentMessages() { sentMessages_.clear(); }
private:
bool running_ = false;
int port_ = 0;
int nextId_ = 1;
std::map<std::string, std::vector<std::string>> sentMessages_;
};

View File

@@ -63,6 +63,19 @@ public:
return result;
}
// Remove a child from whatever role it belongs to
bool removeChild(ASTNode* child) {
for (auto& [role, kids] : children_) {
auto it = std::find(kids.begin(), kids.end(), child);
if (it != kids.end()) {
(*it)->parent = nullptr;
kids.erase(it);
return true;
}
}
return false;
}
private:
std::map<std::string, std::vector<ASTNode*>> children_;
};

View File

@@ -0,0 +1,300 @@
// Step 59 TDD Test: WebSocket agent endpoint
//
// Tests the WebSocketAgentServer with MockWebSocketTransport:
// 1. Server starts and reports running
// 2. Agent connects and receives a session ID
// 3. Agent sends JSON-RPC `ping`, receives `pong` with session ID
// 4. Multiple agents get unique session IDs
// 5. Agent disconnect is tracked (session marked inactive)
// 6. Session event callbacks fire on connect/disconnect
// 7. Invalid JSON produces a parse-error response
// 8. Unknown method produces method-not-found error
// 9. Custom request handler delegation works
// 10. listSessions returns all active agents
#include <iostream>
#include <string>
#include <cassert>
#include <vector>
#include <memory>
#include <nlohmann/json.hpp>
#include "WebSocketServer.h"
using json = nlohmann::json;
static bool contains(const std::string& haystack, const std::string& needle) {
return haystack.find(needle) != std::string::npos;
}
int main() {
int passed = 0;
int failed = 0;
// --- Test 1: Server starts and reports running ---
{
auto transport = std::make_unique<MockWebSocketTransport>();
auto* tp = transport.get(); // keep raw ptr for test helpers
WebSocketAgentServer server(std::move(transport));
assert(!server.isRunning() && "Server should not be running before start");
bool ok = server.start(9090);
assert(ok && "Server should start successfully");
assert(server.isRunning() && "Server should be running after start");
assert(server.getPort() == 9090 && "Port should match");
server.stop();
assert(!server.isRunning() && "Server should stop");
std::cout << "Test 1 PASS: Server starts and stops correctly" << std::endl;
++passed;
}
// --- Test 2: Agent connects and gets session ID ---
{
auto transport = std::make_unique<MockWebSocketTransport>();
auto* tp = transport.get();
WebSocketAgentServer server(std::move(transport));
server.start(9090);
std::string sid = tp->simulateConnect();
assert(!sid.empty() && "Session ID should not be empty");
const AgentSession* session = server.getSession(sid);
assert(session != nullptr && "Session should exist after connect");
assert(session->connected && "Session should be marked connected");
assert(session->sessionId == sid && "Session ID should match");
assert(server.getActiveSessionCount() == 1 && "Should have 1 active session");
std::cout << "Test 2 PASS: Agent connects and gets session ID" << std::endl;
++passed;
}
// --- Test 3: Agent sends ping, receives pong with session ID ---
{
auto transport = std::make_unique<MockWebSocketTransport>();
auto* tp = transport.get();
WebSocketAgentServer server(std::move(transport));
server.start(9090);
std::string sid = tp->simulateConnect();
json pingRequest = {
{"jsonrpc", "2.0"},
{"id", 1},
{"method", "ping"}
};
tp->simulateMessage(sid, pingRequest.dump());
std::string response = tp->getLastSentMessage(sid);
assert(!response.empty() && "Should receive a response");
json resp = json::parse(response);
assert(resp.contains("result") && "Response should have result");
assert(resp["result"]["message"] == "pong" && "Result message should be pong");
assert(resp["result"]["sessionId"] == sid && "Result should include session ID");
assert(resp["id"] == 1 && "Response ID should match request");
std::cout << "Test 3 PASS: ping returns pong with session ID" << std::endl;
++passed;
}
// --- Test 4: Multiple agents get unique session IDs ---
{
auto transport = std::make_unique<MockWebSocketTransport>();
auto* tp = transport.get();
WebSocketAgentServer server(std::move(transport));
server.start(9090);
std::string sid1 = tp->simulateConnect();
std::string sid2 = tp->simulateConnect();
std::string sid3 = tp->simulateConnect();
assert(sid1 != sid2 && "Session IDs should be unique");
assert(sid2 != sid3 && "Session IDs should be unique");
assert(sid1 != sid3 && "Session IDs should be unique");
assert(server.getActiveSessionCount() == 3 && "Should have 3 active sessions");
std::cout << "Test 4 PASS: Multiple agents get unique session IDs" << std::endl;
++passed;
}
// --- Test 5: Agent disconnect is tracked ---
{
auto transport = std::make_unique<MockWebSocketTransport>();
auto* tp = transport.get();
WebSocketAgentServer server(std::move(transport));
server.start(9090);
std::string sid = tp->simulateConnect();
assert(server.getActiveSessionCount() == 1);
tp->simulateDisconnect(sid);
const AgentSession* session = server.getSession(sid);
assert(session != nullptr && "Session should still exist");
assert(!session->connected && "Session should be marked disconnected");
assert(server.getActiveSessionCount() == 0 && "No active sessions after disconnect");
std::cout << "Test 5 PASS: Agent disconnect tracked correctly" << std::endl;
++passed;
}
// --- Test 6: Session event callbacks fire ---
{
auto transport = std::make_unique<MockWebSocketTransport>();
auto* tp = transport.get();
WebSocketAgentServer server(std::move(transport));
std::vector<std::string> events;
server.setSessionEventCallback(
[&events](const AgentSession& s, const std::string& event) {
events.push_back(s.sessionId + ":" + event);
});
server.start(9090);
std::string sid = tp->simulateConnect();
assert(events.size() == 1 && "Should have 1 event after connect");
assert(contains(events[0], "connected") && "Event should be 'connected'");
tp->simulateDisconnect(sid);
assert(events.size() == 2 && "Should have 2 events after disconnect");
assert(contains(events[1], "disconnected") && "Event should be 'disconnected'");
std::cout << "Test 6 PASS: Session event callbacks fire correctly" << std::endl;
++passed;
}
// --- Test 7: Invalid JSON produces parse-error response ---
{
auto transport = std::make_unique<MockWebSocketTransport>();
auto* tp = transport.get();
WebSocketAgentServer server(std::move(transport));
server.start(9090);
std::string sid = tp->simulateConnect();
tp->simulateMessage(sid, "this is not json!!!");
std::string response = tp->getLastSentMessage(sid);
json resp = json::parse(response);
assert(resp.contains("error") && "Should have error for invalid JSON");
assert(resp["error"]["code"] == -32700 && "Error code should be -32700 (Parse error)");
std::cout << "Test 7 PASS: Invalid JSON returns parse error" << std::endl;
++passed;
}
// --- Test 8: Unknown method produces method-not-found error ---
{
auto transport = std::make_unique<MockWebSocketTransport>();
auto* tp = transport.get();
WebSocketAgentServer server(std::move(transport));
server.start(9090);
std::string sid = tp->simulateConnect();
json req = {
{"jsonrpc", "2.0"},
{"id", 42},
{"method", "nonExistentMethod"}
};
tp->simulateMessage(sid, req.dump());
std::string response = tp->getLastSentMessage(sid);
json resp = json::parse(response);
assert(resp.contains("error") && "Should have error for unknown method");
assert(resp["error"]["code"] == -32601 && "Error code should be -32601 (Method not found)");
assert(resp["id"] == 42 && "Response ID should match request");
std::cout << "Test 8 PASS: Unknown method returns method-not-found error" << std::endl;
++passed;
}
// --- Test 9: Custom request handler delegation ---
{
auto transport = std::make_unique<MockWebSocketTransport>();
auto* tp = transport.get();
WebSocketAgentServer server(std::move(transport));
// Register a custom handler that knows about "getAST"
server.setRequestHandler([](const json& request) -> json {
std::string method = request.at("method").get<std::string>();
json response;
response["jsonrpc"] = "2.0";
response["id"] = request.contains("id") ? request["id"] : json(nullptr);
if (method == "getAST") {
response["result"] = {{"concept", "Module"}, {"name", "TestModule"}};
} else {
response["error"] = {{"code", -32601}, {"message", "Method not found"}};
}
return response;
});
server.start(9090);
std::string sid = tp->simulateConnect();
json req = {
{"jsonrpc", "2.0"},
{"id", 10},
{"method", "getAST"}
};
tp->simulateMessage(sid, req.dump());
std::string response = tp->getLastSentMessage(sid);
json resp = json::parse(response);
assert(resp.contains("result") && "Delegated handler should return result");
assert(resp["result"]["concept"] == "Module" && "Should get Module from handler");
assert(resp["id"] == 10 && "Response ID should match");
std::cout << "Test 9 PASS: Custom request handler delegation works" << std::endl;
++passed;
}
// --- Test 10: listSessions returns all active agents ---
{
auto transport = std::make_unique<MockWebSocketTransport>();
auto* tp = transport.get();
WebSocketAgentServer server(std::move(transport));
server.start(9090);
std::string sid1 = tp->simulateConnect();
std::string sid2 = tp->simulateConnect();
// Set agent name on sid1
json nameReq = {
{"jsonrpc", "2.0"},
{"id", 1},
{"method", "setAgentName"},
{"params", {{"name", "CodeAssistant"}}}
};
tp->simulateMessage(sid1, nameReq.dump());
// Disconnect sid2
tp->simulateDisconnect(sid2);
// Ask for session list from sid1
json listReq = {
{"jsonrpc", "2.0"},
{"id", 2},
{"method", "listSessions"}
};
tp->simulateMessage(sid1, listReq.dump());
std::string response = tp->getLastSentMessage(sid1);
json resp = json::parse(response);
assert(resp.contains("result") && "Should have result");
assert(resp["result"].is_array() && "Result should be an array");
// Only sid1 should be active (sid2 disconnected)
assert(resp["result"].size() == 1 && "Should have 1 active session");
assert(resp["result"][0]["agentName"] == "CodeAssistant" &&
"Agent name should be set");
std::cout << "Test 10 PASS: listSessions returns active agents" << std::endl;
++passed;
}
// --- Summary ---
std::cout << "\n=== Step 59 Results: " << passed << " passed, "
<< failed << " failed ===" << std::endl;
return failed > 0 ? 1 : 0;
}

View File

@@ -1,13 +1,12 @@
// Step 61 TDD Test: AST mutation API (extend)
//
// Tests extended mutation API with validation and lock checking:
// 1. updateNode bulk property update
// 2. Mutation recorded in operation journal
// 3. Mutation on OptimizationLock node produces warning (not rejection)
// 4. deleteNode removes a node from its parent
// 5. insertNode adds a node at the correct position
//
// Will fail until the extended mutation API is implemented.
// 1. setProperty updates a node property
// 2. updateNode bulk property update
// 3. Mutation recorded in operation journal
// 4. Mutation on OptimizationLock node produces warning (not rejection)
// 5. deleteNode removes a node from its parent
// 6. insertNode adds a node at the correct position
#include <iostream>
#include <string>
@@ -20,42 +19,7 @@
#include "ast/Statement.h"
#include "ast/Expression.h"
#include "ast/Annotation.h"
// Forward declaration — ASTMutationAPI
class ASTMutationAPI {
public:
struct MutationResult {
bool success;
std::string warning; // Non-empty if a warning was produced
std::string error; // Non-empty if mutation failed
};
// Set the root AST to mutate
void setRoot(ASTNode* root);
// Update multiple properties on a node at once
MutationResult updateNode(const std::string& nodeId,
const std::map<std::string, std::string>& properties);
// Delete a node by ID (removes from parent)
MutationResult deleteNode(const std::string& nodeId);
// Insert a node as child of parentId in the given role
MutationResult insertNode(const std::string& parentId,
const std::string& role,
ASTNode* node);
// Set a property on a node
MutationResult setProperty(const std::string& nodeId,
const std::string& property,
const std::string& value);
// Get operation journal entries since last call
std::vector<std::string> getJournalEntries() const;
// Clear journal
void clearJournal();
};
#include "ASTMutationAPI.h"
int main() {
int passed = 0;

View File

@@ -5,8 +5,6 @@
// 2. getCallHierarchy returns callers and callees
// 3. getDependencyGraph returns data flow dependencies
// 4. Scope respects nesting (inner scope sees outer, not vice versa)
//
// Will fail until the Context API is implemented.
#include <iostream>
#include <string>
@@ -20,35 +18,7 @@
#include "ast/Parameter.h"
#include "ast/Statement.h"
#include "ast/Expression.h"
// Forward declaration — ContextAPI
class ContextAPI {
public:
struct Symbol {
std::string name;
std::string kind; // "function", "variable", "parameter"
std::string nodeId;
};
struct CallInfo {
std::string functionId;
std::string functionName;
std::vector<std::string> callerIds; // Functions that call this one
std::vector<std::string> calleeIds; // Functions called by this one
};
// Set the root AST
void setRoot(ASTNode* root);
// Get symbols in scope at a given node position
std::vector<Symbol> getInScopeSymbols(const std::string& nodeId) const;
// Get call hierarchy for a function
CallInfo getCallHierarchy(const std::string& functionId) const;
// Get data dependency node IDs for a given node
std::vector<std::string> getDependencyGraph(const std::string& nodeId) const;
};
#include "ContextAPI.h"
static bool hasSymbol(const std::vector<ContextAPI::Symbol>& symbols, const std::string& name) {
return std::any_of(symbols.begin(), symbols.end(),

View File

@@ -4,9 +4,8 @@
// 1. applySequence of mutations applies all atomically
// 2. If one mutation in the batch fails, all preceding mutations roll back
// 3. Successful batch records all operations in journal
// 4. Partial failure doesn't leave AST in inconsistent state
//
// Will fail until applySequence with rollback is implemented.
// 4. Empty batch succeeds trivially
// 5. Partial failure doesn't leave AST in inconsistent state
#include <iostream>
#include <string>
@@ -19,38 +18,7 @@
#include "ast/Function.h"
#include "ast/Variable.h"
#include "ast/Expression.h"
// Forward declaration — BatchMutationAPI
class BatchMutationAPI {
public:
struct Mutation {
std::string type; // "setProperty", "insertNode", "deleteNode"
std::string nodeId;
std::string property; // For setProperty
std::string value; // For setProperty
std::string parentId; // For insertNode
std::string role; // For insertNode
ASTNode* newNode = nullptr; // For insertNode
};
struct BatchResult {
bool success;
int appliedCount; // How many mutations were applied before failure
std::string error; // Error message if failed
};
// Set the root AST
void setRoot(ASTNode* root);
// Apply a sequence of mutations atomically (all-or-nothing)
BatchResult applySequence(const std::vector<Mutation>& mutations);
// Get journal entries
std::vector<std::string> getJournalEntries() const;
// Clear journal
void clearJournal();
};
#include "BatchMutationAPI.h"
int main() {
int passed = 0;