From fcccaeb86628a72a3b86df526098a41db4f044b5 Mon Sep 17 00:00:00 2001 From: Bill Date: Wed, 11 Feb 2026 15:45:31 +0000 Subject: [PATCH] Step 252: diagnostic delta streaming (getDiagnosticsDelta, version tracking) DiagnosticVersionTracker records snapshots and computes added/removed deltas between versions. Agents can efficiently check if their fix resolved an error without re-fetching all diagnostics. 21 MCP tools total. Co-Authored-By: Claude Opus 4.6 --- editor/CMakeLists.txt | 11 + editor/src/AgentPermissionPolicy.h | 1 + editor/src/HeadlessAgentRPCHandler.h | 21 +- editor/src/HeadlessEditorState.h | 1 + editor/src/MCPServer.h | 17 ++ editor/src/StructuredDiagnostics.h | 112 ++++++++ editor/tests/step252_test.cpp | 392 +++++++++++++++++++++++++++ progress.md | 32 +++ 8 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 editor/tests/step252_test.cpp diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 3ff6860..3e403e5 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1446,4 +1446,15 @@ target_link_libraries(step251_test PRIVATE tree_sitter_java tree_sitter_rust tree_sitter_go tree_sitter_org) +# Step 252: Diagnostic delta streaming +add_executable(step252_test tests/step252_test.cpp) +target_include_directories(step252_test PRIVATE src) +target_link_libraries(step252_test PRIVATE + nlohmann_json::nlohmann_json + unofficial::tree-sitter::tree-sitter + tree_sitter_python tree_sitter_cpp tree_sitter_elisp + tree_sitter_javascript tree_sitter_typescript + tree_sitter_java tree_sitter_rust tree_sitter_go + tree_sitter_org) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/AgentPermissionPolicy.h b/editor/src/AgentPermissionPolicy.h index b4e0b1b..5ad23da 100644 --- a/editor/src/AgentPermissionPolicy.h +++ b/editor/src/AgentPermissionPolicy.h @@ -48,6 +48,7 @@ struct AgentPermissionPolicy { method == "getASTSubtree" || method == "getASTDiff" || method == "getDiagnostics" || + method == "getDiagnosticsDelta" || method == "getQuickFixes") { return true; } diff --git a/editor/src/HeadlessAgentRPCHandler.h b/editor/src/HeadlessAgentRPCHandler.h index a5a148f..89b1e8c 100644 --- a/editor/src/HeadlessAgentRPCHandler.h +++ b/editor/src/HeadlessAgentRPCHandler.h @@ -598,13 +598,32 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, std::string src = params.value("source", ""); diags = filterBySource(diags, src); } + // Record snapshot for delta tracking + auto allDiagsUnfiltered = collectAllDiagnostics(state.activeAST()); + state.active()->diagTracker.recordSnapshot(allDiagsUnfiltered); return headlessRpcResult(id, { {"diagnostics", diagnosticsToJson(diags)}, {"count", (int)diags.size()}, - {"version", state.active()->versionTracker.version} + {"version", state.active()->diagTracker.version} }); } + // --- getDiagnosticsDelta --- + if (method == "getDiagnosticsDelta") { + 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(); + int sinceVersion = params.value("sinceVersion", 0); + auto currentDiags = collectAllDiagnostics(state.activeAST()); + state.active()->diagTracker.recordSnapshot(currentDiags); + auto delta = state.active()->diagTracker.getDelta( + currentDiags, sinceVersion); + return headlessRpcResult(id, deltaToJson(delta)); + } + // --- getQuickFixes --- if (method == "getQuickFixes") { if (!AgentPermissionPolicy::canInvoke(role, method)) diff --git a/editor/src/HeadlessEditorState.h b/editor/src/HeadlessEditorState.h index aaa4e42..78571cb 100644 --- a/editor/src/HeadlessEditorState.h +++ b/editor/src/HeadlessEditorState.h @@ -67,6 +67,7 @@ struct HeadlessBufferState { Orchestrator orchestrator; IncrementalOptimizer incrementalOptimizer; ASTVersionTracker versionTracker; + DiagnosticVersionTracker diagTracker; std::string language = "python"; std::string path = "(untitled)"; std::string editBuf; diff --git a/editor/src/MCPServer.h b/editor/src/MCPServer.h index 067a79b..c9e82bb 100644 --- a/editor/src/MCPServer.h +++ b/editor/src/MCPServer.h @@ -669,6 +669,23 @@ private: return callWhetstone("getDiagnostics", args); }; + // whetstone_get_diagnostics_delta + tools_.push_back({"whetstone_get_diagnostics_delta", + "Get only the diagnostics that changed since a given version. " + "Returns added and removed diagnostics for efficient " + "mutate-then-check loops. Use the version from a previous " + "getDiagnostics or getDiagnosticsDelta response.", + {{"type", "object"}, {"properties", { + {"sinceVersion", {{"type", "integer"}, + {"description", + "Version number from a previous diagnostics response"}}} + }}, {"required", {"sinceVersion"}}} + }); + toolHandlers_["whetstone_get_diagnostics_delta"] = + [this](const json& args) { + return callWhetstone("getDiagnosticsDelta", args); + }; + // whetstone_get_quick_fixes tools_.push_back({"whetstone_get_quick_fixes", "Get all applicable quick-fix actions for a node or the entire " diff --git a/editor/src/StructuredDiagnostics.h b/editor/src/StructuredDiagnostics.h index d02b8e7..7915eda 100644 --- a/editor/src/StructuredDiagnostics.h +++ b/editor/src/StructuredDiagnostics.h @@ -409,3 +409,115 @@ inline QuickFix findQuickFix(Module* ast, const std::string& diagCode, } return {}; // empty fix (mutation will be null) } + +// ----------------------------------------------------------------------- +// Step 252: DiagnosticVersionTracker — track diagnostic changes +// ----------------------------------------------------------------------- + +// Key that identifies a unique diagnostic instance +struct DiagnosticKey { + std::string code; + std::string nodeId; + int line = 0; + + bool operator==(const DiagnosticKey& o) const { + return code == o.code && nodeId == o.nodeId && line == o.line; + } + bool operator<(const DiagnosticKey& o) const { + if (code != o.code) return code < o.code; + if (nodeId != o.nodeId) return nodeId < o.nodeId; + return line < o.line; + } +}; + +inline DiagnosticKey keyFromDiagnostic(const StructuredDiagnostic& d) { + return {d.code, d.nodeId, d.line}; +} + +struct DiagnosticDelta { + std::vector added; + std::vector removed; + int version = 0; +}; + +class DiagnosticVersionTracker { +public: + int version = 0; + + // Record current diagnostics snapshot and bump version + void recordSnapshot(const std::vector& diags) { + previousDiags_ = currentDiags_; + previousVersion_ = version; + currentDiags_ = diags; + ++version; + } + + // Compute delta: what changed since the given version + DiagnosticDelta getDelta( + const std::vector& currentDiags, + int sinceVersion) const { + DiagnosticDelta delta; + delta.version = version; + + if (sinceVersion >= version) { + // No changes since that version + return delta; + } + + // Build key sets for previous and current + std::set prevKeys; + std::map prevMap; + for (const auto& d : previousDiags_) { + auto key = keyFromDiagnostic(d); + prevKeys.insert(key); + prevMap[key] = d; + } + + std::set currKeys; + std::map currMap; + for (const auto& d : currentDiags) { + auto key = keyFromDiagnostic(d); + currKeys.insert(key); + currMap[key] = d; + } + + // Added: in current but not in previous + for (const auto& key : currKeys) { + if (prevKeys.find(key) == prevKeys.end()) { + delta.added.push_back(currMap[key]); + } + } + + // Removed: in previous but not in current + for (const auto& key : prevKeys) { + if (currKeys.find(key) == currKeys.end()) { + delta.removed.push_back(prevMap[key]); + } + } + + return delta; + } + + // Reset tracker + void reset() { + version = 0; + previousVersion_ = 0; + currentDiags_.clear(); + previousDiags_.clear(); + } + +private: + int previousVersion_ = 0; + std::vector currentDiags_; + std::vector previousDiags_; +}; + +inline json deltaToJson(const DiagnosticDelta& delta) { + return { + {"added", diagnosticsToJson(delta.added)}, + {"removed", diagnosticsToJson(delta.removed)}, + {"addedCount", (int)delta.added.size()}, + {"removedCount", (int)delta.removed.size()}, + {"version", delta.version} + }; +} diff --git a/editor/tests/step252_test.cpp b/editor/tests/step252_test.cpp new file mode 100644 index 0000000..920ce81 --- /dev/null +++ b/editor/tests/step252_test.cpp @@ -0,0 +1,392 @@ +// Step 252 TDD Test: Diagnostic Delta Streaming +// +// Tests getDiagnosticsDelta RPC method: version tracking, added/removed +// diagnostics after mutations, efficient delta computation, and MCP tool. +#include "HeadlessEditorState.h" +#include "MCPBridge.h" + +#include +#include + +static void expect(bool cond, const std::string& name, + int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) + << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) + << " FAIL: " << name << "\n"; + ++failed; + } +} + +int main() { + int passed = 0; + int failed = 0; + + // --------------------------------------------------------------- + // Test 1: getDiagnostics returns version number + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("v.py", + "def add(a, b):\n return a + b\n", "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + json req = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "getDiagnostics"}, + {"params", json::object()}}; + json resp = state.processAgentRequest(req, "s1"); + + bool hasVersion = resp.contains("result") && + resp["result"].contains("version"); + int ver = 0; + if (hasVersion) ver = resp["result"].value("version", -1); + expect(hasVersion && ver >= 1, + "getDiagnostics returns version >= 1 (got " + + std::to_string(ver) + ")", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 2: getDiagnosticsDelta with same version → empty delta + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("d.py", + "def add(a, b):\n return a + b\n", "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + // Get initial diagnostics to set version + json diagReq = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "getDiagnostics"}, + {"params", json::object()}}; + json diagResp = state.processAgentRequest(diagReq, "s1"); + int ver = diagResp["result"].value("version", 0); + + // Get delta with same version → no changes + json deltaReq = {{"jsonrpc", "2.0"}, {"id", 2}, + {"method", "getDiagnosticsDelta"}, + {"params", {{"sinceVersion", ver}}}}; + json deltaResp = state.processAgentRequest(deltaReq, "s1"); + + bool hasResult = deltaResp.contains("result"); + int addedCount = 0, removedCount = 0; + if (hasResult) { + addedCount = deltaResp["result"].value("addedCount", -1); + removedCount = deltaResp["result"].value("removedCount", -1); + } + expect(hasResult && addedCount == 0 && removedCount == 0, + "Delta with current version returns empty (added=0, removed=0)", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 3: Introduce annotation error → delta shows addition + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("intro.py", + "def cleanup(ptr):\n x = ptr\n", "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + // Get initial diagnostics (clean state) + json req1 = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "getDiagnostics"}, + {"params", json::object()}}; + json resp1 = state.processAgentRequest(req1, "s1"); + int ver1 = resp1["result"].value("version", 0); + int count1 = resp1["result"].value("count", 0); + + // Add annotation to introduce an error + Module* ast = state.activeAST(); + for (auto* c : ast->allChildren()) { + if (c->conceptType == "Function") { + auto* anno = new DeallocateAnnotation(); + c->addChild("annotations", anno); + break; + } + } + + // Get delta → should show additions + json deltaReq = {{"jsonrpc", "2.0"}, {"id", 2}, + {"method", "getDiagnosticsDelta"}, + {"params", {{"sinceVersion", ver1}}}}; + json deltaResp = state.processAgentRequest(deltaReq, "s1"); + + int addedCount = 0; + if (deltaResp.contains("result")) + addedCount = deltaResp["result"].value("addedCount", 0); + expect(addedCount > 0, + "Delta after introducing error shows additions (added=" + + std::to_string(addedCount) + ")", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 4: Fix annotation error → delta shows removal + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("fix.py", + "def cleanup(ptr):\n x = ptr\n", "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + // Add annotation to create an error + Module* ast = state.activeAST(); + ASTNode* fn = nullptr; + for (auto* c : ast->allChildren()) { + if (c->conceptType == "Function") { fn = c; break; } + } + if (fn) { + auto* anno = new DeallocateAnnotation(); + fn->addChild("annotations", anno); + } + + // Get diagnostics with the error (establishes baseline) + json req1 = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "getDiagnostics"}, + {"params", json::object()}}; + json resp1 = state.processAgentRequest(req1, "s1"); + int ver1 = resp1["result"].value("version", 0); + int errCount = resp1["result"].value("count", 0); + + // Remove the annotation to fix the error + if (fn) { + auto annos = fn->getChildren("annotations"); + for (auto* a : annos) { + if (a->conceptType == "DeallocateAnnotation") { + fn->removeChild(a); + delete a; + break; + } + } + } + + // Get delta → should show removals + json deltaReq = {{"jsonrpc", "2.0"}, {"id", 2}, + {"method", "getDiagnosticsDelta"}, + {"params", {{"sinceVersion", ver1}}}}; + json deltaResp = state.processAgentRequest(deltaReq, "s1"); + + int removedCount = 0; + if (deltaResp.contains("result")) + removedCount = deltaResp["result"].value("removedCount", 0); + expect(errCount > 0 && removedCount > 0, + "Delta after fix shows removals (had=" + + std::to_string(errCount) + " removed=" + + std::to_string(removedCount) + ")", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 5: Delta response has correct structure + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("struct.py", + "def foo():\n pass\n", "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + json req = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "getDiagnostics"}, + {"params", json::object()}}; + state.processAgentRequest(req, "s1"); + + json deltaReq = {{"jsonrpc", "2.0"}, {"id", 2}, + {"method", "getDiagnosticsDelta"}, + {"params", {{"sinceVersion", 0}}}}; + json resp = state.processAgentRequest(deltaReq, "s1"); + + bool hasAdded = resp.contains("result") && + resp["result"].contains("added") && + resp["result"]["added"].is_array(); + bool hasRemoved = resp.contains("result") && + resp["result"].contains("removed") && + resp["result"]["removed"].is_array(); + bool hasVersion = resp.contains("result") && + resp["result"].contains("version"); + bool hasCounts = resp.contains("result") && + resp["result"].contains("addedCount") && + resp["result"].contains("removedCount"); + + expect(hasAdded && hasRemoved && hasVersion && hasCounts, + "Delta has added, removed, version, addedCount, removedCount", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 6: Version increments on each getDiagnostics call + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("ver.py", + "def foo():\n pass\n", "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + json req = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "getDiagnostics"}, + {"params", json::object()}}; + json r1 = state.processAgentRequest(req, "s1"); + int v1 = r1["result"].value("version", 0); + + json r2 = state.processAgentRequest(req, "s1"); + int v2 = r2["result"].value("version", 0); + + expect(v2 == v1 + 1, + "Version increments: " + std::to_string(v1) + + " → " + std::to_string(v2), + passed, failed); + } + + // --------------------------------------------------------------- + // Test 7: DiagnosticKey comparison works correctly + // --------------------------------------------------------------- + { + DiagnosticKey a{"E0200", "func_1", 10}; + DiagnosticKey b{"E0200", "func_1", 10}; + DiagnosticKey c{"E0201", "func_1", 10}; + DiagnosticKey d{"E0200", "func_2", 10}; + + expect(a == b && !(a == c) && !(a == d) && a < c && a < d, + "DiagnosticKey equality and ordering", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 8: DiagnosticVersionTracker reset clears state + // --------------------------------------------------------------- + { + DiagnosticVersionTracker tracker; + StructuredDiagnostic d; + d.code = "E0100"; d.source = "parser"; d.line = 1; + tracker.recordSnapshot({d}); + expect(tracker.version == 1, "version=1 before reset", + passed, failed); + // (extra check after reset will be in test 9) + } + + // --------------------------------------------------------------- + // Test 9: Tracker reset returns to version 0 + // --------------------------------------------------------------- + { + DiagnosticVersionTracker tracker; + StructuredDiagnostic d; + d.code = "E0100"; d.source = "parser"; d.line = 1; + tracker.recordSnapshot({d}); + tracker.reset(); + expect(tracker.version == 0, + "Tracker reset returns to version 0", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 10: MCP tools/list includes diagnostics delta tool + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("mcp.py", "x = 1\n", "python"); + state.setAgentRole("mcp-session", AgentRole::Refactor); + + MCPBridge bridge; + bridge.setRequestHandler([&state](const json& req) -> json { + return state.processAgentRequest(req, "mcp-session"); + }); + + json req = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "tools/list"}}; + std::string resp = bridge.processMessage(req.dump()); + json r = json::parse(resp); + + bool found = false; + int toolCount = 0; + if (r.contains("result") && r["result"].contains("tools")) { + toolCount = (int)r["result"]["tools"].size(); + for (const auto& t : r["result"]["tools"]) { + if (t.value("name", "") == + "whetstone_get_diagnostics_delta") + found = true; + } + } + expect(found && toolCount >= 21, + "MCP tools/list has diagnostics_delta (total " + + std::to_string(toolCount) + ")", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 11: Delta added diagnostics have structured format + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("fmt.py", + "def cleanup(ptr):\n x = ptr\n", "python"); + state.setAgentRole("s1", AgentRole::Refactor); + + // Baseline (no errors) + json req1 = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "getDiagnostics"}, + {"params", json::object()}}; + state.processAgentRequest(req1, "s1"); + + // Add annotation error + for (auto* c : state.activeAST()->allChildren()) { + if (c->conceptType == "Function") { + c->addChild("annotations", new DeallocateAnnotation()); + break; + } + } + + json deltaReq = {{"jsonrpc", "2.0"}, {"id", 2}, + {"method", "getDiagnosticsDelta"}, + {"params", {{"sinceVersion", 0}}}}; + json resp = state.processAgentRequest(deltaReq, "s1"); + + bool validFormat = false; + if (resp.contains("result") && + !resp["result"]["added"].empty()) { + const auto& d = resp["result"]["added"][0]; + validFormat = d.contains("code") && + d.contains("severity") && + d.contains("message") && + d.contains("source"); + } + expect(validFormat, + "Delta added diagnostics have structured format", + passed, failed); + } + + // --------------------------------------------------------------- + // Test 12: Linter role can access getDiagnosticsDelta + // --------------------------------------------------------------- + { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("perm.py", + "def foo():\n pass\n", "python"); + state.setAgentRole("linter", AgentRole::Linter); + + json req = {{"jsonrpc", "2.0"}, {"id", 1}, + {"method", "getDiagnosticsDelta"}, + {"params", {{"sinceVersion", 0}}}}; + json resp = state.processAgentRequest(req, "linter"); + + bool hasResult = resp.contains("result"); + expect(hasResult, + "Linter role can access getDiagnosticsDelta", + passed, failed); + } + + std::cout << "\n=== Step 252 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index 1f6237c..feb2372 100644 --- a/progress.md +++ b/progress.md @@ -220,3 +220,35 @@ diagnostic cleared. - After applying, re-runs diagnostics to report if the error cleared - tools/list now returns 20 tools (was 18): +whetstone_get_quick_fixes, +whetstone_apply_quick_fix + +### Step 252: Diagnostic Delta Streaming +**Status:** PASS (12/12 tests) + +Efficient delta computation so agents only see what changed since their +last diagnostic query. `getDiagnosticsDelta` returns added/removed diagnostics +based on a version number, enabling fast mutate → check loops. + +**Files created:** +- `editor/tests/step252_test.cpp` — 12 test cases: version tracking, + empty delta, addition detection, removal detection, response structure, + version increment, DiagnosticKey comparison, tracker reset, MCP tool, + structured format in delta, Linter role access + +**Files modified:** +- `editor/src/StructuredDiagnostics.h` — DiagnosticKey (code, nodeId, line), + DiagnosticDelta (added, removed, version), DiagnosticVersionTracker + (recordSnapshot, getDelta, reset), deltaToJson +- `editor/src/HeadlessEditorState.h` — DiagnosticVersionTracker in + HeadlessBufferState +- `editor/src/HeadlessAgentRPCHandler.h` — getDiagnosticsDelta RPC method, + getDiagnostics now records snapshots for delta tracking +- `editor/src/AgentPermissionPolicy.h` — getDiagnosticsDelta as read-only +- `editor/src/MCPServer.h` — whetstone_get_diagnostics_delta tool +- `editor/CMakeLists.txt` — step252_test target + +**Key design decisions:** +- DiagnosticKey tuple (code, nodeId, line) uniquely identifies a diagnostic +- Version bumps on each getDiagnostics/getDiagnosticsDelta call +- Delta computed by set difference between previous and current snapshots +- Response includes addedCount/removedCount for quick checks without parsing +- tools/list now returns 21 tools (was 20): +whetstone_get_diagnostics_delta