diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 5089143..8387e6b 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -2317,4 +2317,13 @@ target_link_libraries(step381_test PRIVATE tree_sitter_javascript tree_sitter_typescript tree_sitter_java tree_sitter_rust tree_sitter_go) +add_executable(step382_test tests/step382_test.cpp) +target_include_directories(step382_test PRIVATE src) +target_link_libraries(step382_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) + # 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 fa1d7a9..d6614ab 100644 --- a/editor/src/AgentPermissionPolicy.h +++ b/editor/src/AgentPermissionPolicy.h @@ -70,7 +70,9 @@ struct AgentPermissionPolicy { method == "getReadyTasks" || method == "getWorkItem" || method == "getRoutingExplanation" || - method == "getReviewPolicy") { + method == "getReviewPolicy" || + method == "getBlockers" || + method == "getProgress") { return true; } @@ -103,7 +105,11 @@ struct AgentPermissionPolicy { method == "routeTask" || method == "routeAllReady" || method == "executeTask" || - method == "setReviewPolicy") { + method == "setReviewPolicy" || + method == "orchestrateStep" || + method == "orchestrateAdvance" || + method == "orchestrateRunDeterministic" || + method == "submitExternalResult") { return role == AgentRole::Refactor || role == AgentRole::Generator; } diff --git a/editor/src/HeadlessAgentRPCHandler.h b/editor/src/HeadlessAgentRPCHandler.h index 5656b6b..0be0b8a 100644 --- a/editor/src/HeadlessAgentRPCHandler.h +++ b/editor/src/HeadlessAgentRPCHandler.h @@ -14,6 +14,7 @@ // Deliberately omits GUI-only methods. Stays under 600 lines. struct HeadlessEditorState; +#include "HeadlessOrchestratorRPC.h" // Re-use the response helpers (same signature as AgentRPCHandler.h) static inline json headlessRpcError(const json& id, int code, @@ -2260,6 +2261,7 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, state.workflow = WorkflowState(projectName); std::string bufferId = state.activeBuffer->path; int count = state.workflow->populateFromSkeleton(state.activeAST(), bufferId); + state.workflowProgress = WorkflowProgress(count); auto stats = state.workflow->getStats(); return headlessRpcResult(id, { @@ -2612,5 +2614,10 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state, return headlessRpcResult(id, state.reviewPolicy.toJson()); } + if (auto routed = tryHandleHeadlessOrchestratorRPC( + state, request, id, method, role); routed.has_value()) { + return routed.value(); + } + return headlessRpcError(id, -32601, "Method not found"); } diff --git a/editor/src/HeadlessEditorState.h b/editor/src/HeadlessEditorState.h index 51a9311..81833d3 100644 --- a/editor/src/HeadlessEditorState.h +++ b/editor/src/HeadlessEditorState.h @@ -43,6 +43,7 @@ #include "WorkerRegistry.h" #include "ContextAssembler.h" #include "ReviewGate.h" +#include "WorkflowProgress.h" #include #include @@ -141,6 +142,7 @@ struct HeadlessEditorState { std::string defaultLanguage = "python"; bool verbose = false; std::optional workflow; + std::optional workflowProgress; RoutingEngine routingEngine; WorkerRegistry workerRegistry = WorkerRegistry::getDefaultRegistry(); ContextAssembler contextAssembler; diff --git a/editor/src/HeadlessOrchestratorRPC.h b/editor/src/HeadlessOrchestratorRPC.h new file mode 100644 index 0000000..4089a77 --- /dev/null +++ b/editor/src/HeadlessOrchestratorRPC.h @@ -0,0 +1,287 @@ +#pragma once +// Step 382: Orchestrator RPC surface for headless agent requests. + +#include "HeadlessEditorState.h" +#include + +static inline json orchestratorRpcError(const json& id, int code, + const std::string& msg) { + return {{"jsonrpc", "2.0"}, {"id", id}, + {"error", {{"code", code}, {"message", msg}}}}; +} + +static inline json orchestratorRpcResult(const json& id, const json& result) { + return {{"jsonrpc", "2.0"}, {"id", id}, {"result", result}}; +} + +static inline json orchestratorEventToJson(const OrchestratorEvent& event) { + return { + {"type", event.type}, + {"itemId", event.itemId}, + {"detail", event.detail}, + {"timestamp", event.timestamp} + }; +} + +static inline json orchestratorEventsToJson( + const std::vector& events) { + json arr = json::array(); + for (const auto& event : events) arr.push_back(orchestratorEventToJson(event)); + return arr; +} + +static inline json orchestratorBlockersToJson(const std::vector& blockers) { + json arr = json::array(); + for (const auto& b : blockers) { + arr.push_back({ + {"type", b.type}, + {"itemIds", b.itemIds}, + {"description", b.description} + }); + } + return arr; +} + +static inline std::map collectOrchestratorBufferInfos( + HeadlessEditorState& state) { + std::map infos; + for (const auto& [path, buf] : state.bufferStates) { + BufferInfo bi; + bi.path = path; + bi.content = buf->editBuf; + Module* ast = buf->sync.getAST(); + bi.compactAst = ast ? toJsonCompactSummary(ast) : json::array(); + bi.importGraph = json::object(); + infos[path] = std::move(bi); + } + return infos; +} + +inline std::optional tryHandleHeadlessOrchestratorRPC( + HeadlessEditorState& state, + const json& request, + const json& id, + const std::string& method, + AgentRole role) { + if (method == "orchestrateStep") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return orchestratorRpcError(id, -32031, "Role not permitted"); + if (!state.workflow) + return orchestratorRpcError(id, -32000, "No active workflow"); + + WorkflowOrchestrator orchestrator(*state.workflow, state.routingEngine, + state.workerRegistry, state.contextAssembler, + state.reviewGate); + orchestrator.setReviewPolicy(state.reviewPolicy); + orchestrator.setBuffers(collectOrchestratorBufferInfos(state)); + + OrchestratorEvent event = orchestrator.step(); + if (!state.workflowProgress) { + state.workflowProgress = WorkflowProgress(state.workflow->getStats().total); + } + state.workflowProgress->recordEvent(event); + return orchestratorRpcResult(id, {{"event", orchestratorEventToJson(event)}}); + } + + if (method == "orchestrateAdvance") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return orchestratorRpcError(id, -32031, "Role not permitted"); + if (!state.workflow) + return orchestratorRpcError(id, -32000, "No active workflow"); + + WorkflowOrchestrator orchestrator(*state.workflow, state.routingEngine, + state.workerRegistry, state.contextAssembler, + state.reviewGate); + orchestrator.setReviewPolicy(state.reviewPolicy); + orchestrator.setBuffers(collectOrchestratorBufferInfos(state)); + + BatchResult batch = orchestrator.advanceBatch(); + if (!state.workflowProgress) { + state.workflowProgress = WorkflowProgress(state.workflow->getStats().total); + } + for (const auto& event : batch.events) { + state.workflowProgress->recordEvent(event); + } + + return orchestratorRpcResult(id, { + {"events", orchestratorEventsToJson(batch.events)}, + {"itemsAdvanced", batch.itemsAdvanced}, + {"itemsBlocked", batch.itemsBlocked}, + {"contextTokensSaved", batch.contextTokensSaved} + }); + } + + if (method == "orchestrateRunDeterministic") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return orchestratorRpcError(id, -32031, "Role not permitted"); + if (!state.workflow) + return orchestratorRpcError(id, -32000, "No active workflow"); + + WorkflowOrchestrator orchestrator(*state.workflow, state.routingEngine, + state.workerRegistry, state.contextAssembler, + state.reviewGate); + orchestrator.setReviewPolicy(state.reviewPolicy); + orchestrator.setBuffers(collectOrchestratorBufferInfos(state)); + + std::vector allEvents; + int totalAdvanced = 0; + int totalBlocked = 0; + int totalSaved = 0; + while (true) { + BatchResult batch = orchestrator.advanceBatch(); + if (batch.events.empty()) break; + bool anyProgress = false; + for (const auto& event : batch.events) { + if (event.type != "blocked") anyProgress = true; + allEvents.push_back(event); + } + totalAdvanced += batch.itemsAdvanced; + totalBlocked += batch.itemsBlocked; + totalSaved += batch.contextTokensSaved; + if (!anyProgress) break; + } + + if (!state.workflowProgress) { + state.workflowProgress = WorkflowProgress(state.workflow->getStats().total); + } + for (const auto& event : allEvents) { + state.workflowProgress->recordEvent(event); + } + + return orchestratorRpcResult(id, { + {"events", orchestratorEventsToJson(allEvents)}, + {"itemsAdvanced", totalAdvanced}, + {"itemsBlocked", totalBlocked}, + {"contextTokensSaved", totalSaved}, + {"stats", state.workflow->getStats().toJson()}, + {"blockers", orchestratorBlockersToJson(orchestrator.getBlockers())} + }); + } + + if (method == "getBlockers") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return orchestratorRpcError(id, -32031, "Role not permitted"); + if (!state.workflow) + return orchestratorRpcError(id, -32000, "No active workflow"); + + WorkflowOrchestrator orchestrator(*state.workflow, state.routingEngine, + state.workerRegistry, state.contextAssembler, + state.reviewGate); + orchestrator.setReviewPolicy(state.reviewPolicy); + orchestrator.setBuffers(collectOrchestratorBufferInfos(state)); + auto blockers = orchestrator.getBlockers(); + return orchestratorRpcResult(id, { + {"blockers", orchestratorBlockersToJson(blockers)}, + {"count", (int)blockers.size()} + }); + } + + if (method == "getProgress") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return orchestratorRpcError(id, -32031, "Role not permitted"); + if (!state.workflow) + return orchestratorRpcError(id, -32000, "No active workflow"); + + if (!state.workflowProgress) { + state.workflowProgress = WorkflowProgress(state.workflow->getStats().total); + } else { + state.workflowProgress->setTotalItems(state.workflow->getStats().total); + } + ProgressSnapshot snapshot = state.workflowProgress->getSnapshot(); + if (snapshot.blockers.empty()) { + WorkflowOrchestrator orchestrator(*state.workflow, state.routingEngine, + state.workerRegistry, state.contextAssembler, + state.reviewGate); + orchestrator.setReviewPolicy(state.reviewPolicy); + orchestrator.setBuffers(collectOrchestratorBufferInfos(state)); + snapshot.blockers = orchestrator.getBlockers(); + } + return orchestratorRpcResult(id, snapshot.toJson()); + } + + if (method == "submitExternalResult") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return orchestratorRpcError(id, -32031, "Role not permitted"); + if (!state.workflow) + return orchestratorRpcError(id, -32000, "No active workflow"); + auto params = request.contains("params") ? request["params"] : json::object(); + std::string itemId = params.value("itemId", ""); + if (itemId.empty()) + return orchestratorRpcError(id, -32602, "Missing itemId"); + if (!params.contains("result") || !params["result"].is_object()) + return orchestratorRpcError(id, -32602, "Missing result payload"); + + auto item = state.workflow->queue.getItem(itemId); + if (!item) + return orchestratorRpcError(id, -32602, "Work item not found"); + if (!(item->workerType == "slm" || item->workerType == "llm")) + return orchestratorRpcError(id, -32000, "submitExternalResult only supports slm/llm items"); + if (item->status != WI_IN_PROGRESS && item->status != WI_ASSIGNED) + return orchestratorRpcError(id, -32000, + "Item must be assigned or in-progress for external submission"); + + WorkItem updated = *item; + if (updated.status == WI_ASSIGNED) transitionWorkItem(updated, WI_IN_PROGRESS); + + const auto& result = params["result"]; + updated.result.generatedCode = result.value("generatedCode", ""); + updated.result.confidence = result.value("confidence", 0.0f); + updated.result.reasoning = result.value("reasoning", ""); + updated.result.tokensGenerated = result.value("tokensGenerated", 0); + updated.result.tokensBudget = result.value("tokensBudget", + estimateContextBudget(updated.contextWidth)); + if (result.contains("astJson")) updated.result.astJson = result["astJson"]; + if (result.contains("diagnostics") && result["diagnostics"].is_array()) { + updated.result.diagnostics = result["diagnostics"].get>(); + } + + state.workflow->queue.updateItem(itemId, updated); + + std::vector events; + events.push_back({ + "executed", + itemId, + {{"workerType", updated.workerType}, + {"confidence", updated.result.confidence}, + {"tokensGenerated", updated.result.tokensGenerated}}, + workItemTimestamp() + }); + + ReviewDecision reviewDecision = + state.reviewGate.shouldAutoApprove(updated, updated.result, state.reviewPolicy); + if (reviewDecision.approved) { + state.workflow->queue.complete(itemId); + events.push_back({"auto-approved", itemId, + {{"rule", reviewDecision.ruleMatched}}, + workItemTimestamp()}); + events.push_back({"completed", itemId, json::object(), workItemTimestamp()}); + } else { + auto maybeUpdated = state.workflow->queue.getItem(itemId); + if (!maybeUpdated) { + return orchestratorRpcError(id, -32000, "Failed to reload item"); + } + WorkItem reviewItem = *maybeUpdated; + transitionWorkItem(reviewItem, WI_REVIEW); + state.workflow->queue.updateItem(itemId, reviewItem); + events.push_back({"sent-to-review", itemId, + {{"reason", reviewDecision.reasoning}}, + workItemTimestamp()}); + } + + if (!state.workflowProgress) { + state.workflowProgress = WorkflowProgress(state.workflow->getStats().total); + } + for (const auto& event : events) { + state.workflowProgress->recordEvent(event); + } + + auto latest = state.workflow->queue.getItem(itemId); + return orchestratorRpcResult(id, { + {"success", true}, + {"events", orchestratorEventsToJson(events)}, + {"item", latest ? workItemToJson(*latest) : json::object()} + }); + } + + return std::nullopt; +} diff --git a/editor/src/MCPServer.h b/editor/src/MCPServer.h index 87ca0ca..5e1b580 100644 --- a/editor/src/MCPServer.h +++ b/editor/src/MCPServer.h @@ -1522,6 +1522,86 @@ private: }; } + void registerOrchestratorTools() { + // whetstone_orchestrate_step + tools_.push_back({"whetstone_orchestrate_step", + "Advance one ready workflow item through the orchestration loop. " + "Returns one orchestrator event (routed/executed/completed/blocked).", + {{"type", "object"}, {"properties", json::object()}} + }); + toolHandlers_["whetstone_orchestrate_step"] = + [this](const json& args) { + return callWhetstone("orchestrateStep", args); + }; + + // whetstone_orchestrate_advance + tools_.push_back({"whetstone_orchestrate_advance", + "Advance all currently ready workflow items in one batch. " + "Returns event stream and batch counters.", + {{"type", "object"}, {"properties", json::object()}} + }); + toolHandlers_["whetstone_orchestrate_advance"] = + [this](const json& args) { + return callWhetstone("orchestrateAdvance", args); + }; + + // whetstone_orchestrate_run_deterministic + tools_.push_back({"whetstone_orchestrate_run_deterministic", + "Run deterministic/template workflow items to completion. " + "Stops automatically at human or external-model blockers.", + {{"type", "object"}, {"properties", json::object()}} + }); + toolHandlers_["whetstone_orchestrate_run_deterministic"] = + [this](const json& args) { + return callWhetstone("orchestrateRunDeterministic", args); + }; + + // whetstone_get_blockers + tools_.push_back({"whetstone_get_blockers", + "Get current workflow blockers grouped by type: needs-human, " + "needs-review, and needs-external-model.", + {{"type", "object"}, {"properties", json::object()}} + }); + toolHandlers_["whetstone_get_blockers"] = + [this](const json& args) { + return callWhetstone("getBlockers", args); + }; + + // whetstone_get_progress + tools_.push_back({"whetstone_get_progress", + "Get workflow progress snapshot: completion %, throughput, ETA, " + "worker stats, and active blockers.", + {{"type", "object"}, {"properties", json::object()}} + }); + toolHandlers_["whetstone_get_progress"] = + [this](const json& args) { + return callWhetstone("getProgress", args); + }; + + // whetstone_submit_result + tools_.push_back({"whetstone_submit_result", + "Submit external SLM/LLM output for a prepared workflow item. " + "Orchestrator applies review gate and advances lifecycle.", + {{"type", "object"}, {"properties", { + {"itemId", {{"type", "string"}, + {"description", "Workflow item ID receiving external output"}}}, + {"result", {{"type", "object"}, {"properties", { + {"generatedCode", {{"type", "string"}}}, + {"confidence", {{"type", "number"}}}, + {"reasoning", {{"type", "string"}}}, + {"tokensGenerated", {{"type", "integer"}}}, + {"tokensBudget", {{"type", "integer"}}}, + {"astJson", {{"type", "object"}}}, + {"diagnostics", {{"type", "array"}, {"items", {{"type", "object"}}}}} + }}}} + }}, {"required", json::array({"itemId", "result"})}} + }); + toolHandlers_["whetstone_submit_result"] = + [this](const json& args) { + return callWhetstone("submitExternalResult", args); + }; + } + void registerReviewTools() { // whetstone_set_review_policy tools_.push_back({"whetstone_set_review_policy", @@ -1566,6 +1646,7 @@ private: registerWorkflowTools(); registerWorkflowExecutionTools(); registerRoutingTools(); + registerOrchestratorTools(); registerReviewTools(); } }; diff --git a/editor/tests/step382_test.cpp b/editor/tests/step382_test.cpp new file mode 100644 index 0000000..240ff7c --- /dev/null +++ b/editor/tests/step382_test.cpp @@ -0,0 +1,250 @@ +// Step 382: Orchestrator RPC + MCP tools (12 tests) + +#include +#include +#include +#include +#include "HeadlessEditorState.h" +#include "HeadlessAgentRPCHandler.h" +#include "MCPServer.h" + +static WorkItem makeItem(const std::string& id, + const std::string& name, + const std::string& workerType = "", + const std::string& contextWidth = "local") { + WorkItem item; + item.id = id; + item.nodeId = id + "_node"; + item.nodeName = name; + item.nodeType = "Function"; + item.bufferId = "main.py"; + item.workerType = workerType; + item.contextWidth = contextWidth; + item.priority = "medium"; + item.status = WI_PENDING; + item.createdAt = workItemTimestamp(); + return item; +} + +static HeadlessEditorState makeState(AgentRole role = AgentRole::Generator) { + HeadlessEditorState state; + state.defaultLanguage = "python"; + state.openBuffer("main.py", "def a():\n return 1\n", "python"); + state.setAgentRole("test-session", role); + state.workflow = WorkflowState("wf"); + return state; +} + +static json rpc(HeadlessEditorState& state, const std::string& method, + const json& params = json::object()) { + json request = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", method}, + {"params", params}}; + return handleHeadlessAgentRequest(state, request, "test-session"); +} + +static json mcpCall(MCPServer& server, const std::string& method, + const json& params = json::object()) { + json request = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", method}, + {"params", params}}; + return server.handleRequest(request); +} + +int main() { + int passed = 0; + + // Test 1: orchestrateStep advances one template task + { + auto state = makeState(); + state.workflow->queue.enqueue(makeItem("t1", "getValue", "template")); + auto resp = rpc(state, "orchestrateStep"); + assert(resp.contains("result")); + assert(resp["result"]["event"]["type"] == "completed"); + std::cout << "Test 1 PASSED: orchestrateStep advances one task\n"; + passed++; + } + + // Test 2: orchestrateAdvance processes a batch of ready items + { + auto state = makeState(); + state.workflow->queue.enqueue(makeItem("a", "getA", "template")); + state.workflow->queue.enqueue(makeItem("b", "getB", "template")); + auto resp = rpc(state, "orchestrateAdvance"); + assert(resp.contains("result")); + assert(resp["result"]["itemsAdvanced"].get() == 2); + std::cout << "Test 2 PASSED: orchestrateAdvance handles batches\n"; + passed++; + } + + // Test 3: orchestrateRunDeterministic stops at blockers + { + auto state = makeState(); + state.workflow->queue.enqueue(makeItem("d1", "getFast", "template")); + state.workflow->queue.enqueue(makeItem("h1", "manual", "human")); + auto resp = rpc(state, "orchestrateRunDeterministic"); + assert(resp.contains("result")); + assert(resp["result"]["stats"]["complete"].get() == 1); + assert(!resp["result"]["blockers"].empty()); + std::cout << "Test 3 PASSED: runDeterministic stops at blockers\n"; + passed++; + } + + // Test 4: getBlockers reports human items + { + auto state = makeState(); + state.workflow->queue.enqueue(makeItem("h1", "manual", "human")); + rpc(state, "orchestrateStep"); // move to blocked human state + auto blockers = rpc(state, "getBlockers"); + assert(blockers.contains("result")); + assert(blockers["result"]["count"].get() >= 1); + std::cout << "Test 4 PASSED: getBlockers reports workflow blockers\n"; + passed++; + } + + // Test 5: getProgress returns valid snapshot fields + { + auto state = makeState(); + state.workflow->queue.enqueue(makeItem("t1", "getV", "template")); + rpc(state, "orchestrateStep"); + auto resp = rpc(state, "getProgress"); + assert(resp.contains("result")); + assert(resp["result"].contains("completionPercent")); + assert(resp["result"].contains("itemsPerMinute")); + assert(resp["result"].contains("byWorkerType")); + std::cout << "Test 5 PASSED: getProgress returns snapshot\n"; + passed++; + } + + // Test 6: submitExternalResult accepts LLM output and advances task + { + auto state = makeState(); + state.reviewPolicy.defaultAction = "auto-approve"; + WorkItem llm = makeItem("x1", "buildGraph", "", "project"); + state.workflow->queue.enqueue(llm); + rpc(state, "orchestrateStep"); // routes to llm, blocks in-progress + auto submit = rpc(state, "submitExternalResult", { + {"itemId", "x1"}, + {"result", { + {"generatedCode", "def buildGraph(data):\n return {}"}, + {"confidence", 0.92}, + {"reasoning", "external model result"}, + {"tokensGenerated", 40} + }} + }); + assert(submit.contains("result")); + assert(submit["result"]["success"].get()); + auto item = state.workflow->queue.getItem("x1"); + assert(item.has_value()); + assert(item->status == WI_COMPLETE); + std::cout << "Test 6 PASSED: submitExternalResult advances agent task\n"; + passed++; + } + + // Test 7: linter can read progress but cannot orchestrate + { + auto state = makeState(AgentRole::Linter); + state.workflow->queue.enqueue(makeItem("l1", "getName", "template")); + auto progress = rpc(state, "getProgress"); + auto step = rpc(state, "orchestrateStep"); + assert(progress.contains("result")); + assert(step.contains("error")); + std::cout << "Test 7 PASSED: linter read-only orchestration access\n"; + passed++; + } + + // Test 8: MCP tool registration includes orchestrator tools + { + MCPServer server; + const auto& tools = server.getTools(); + std::vector needed = { + "whetstone_orchestrate_step", + "whetstone_orchestrate_advance", + "whetstone_orchestrate_run_deterministic", + "whetstone_get_blockers", + "whetstone_get_progress", + "whetstone_submit_result" + }; + int found = 0; + for (const auto& t : tools) { + for (const auto& n : needed) { + if (t.name == n) found++; + } + } + assert(found == 6); + std::cout << "Test 8 PASSED: MCP orchestrator tools registered\n"; + passed++; + } + + // Test 9: MCP orchestrate_run_deterministic executes through callback + { + auto state = makeState(); + state.workflow->queue.enqueue(makeItem("m1", "getOne", "template")); + MCPServer server; + server.setRpcCallback([&state](const json& request) { + return handleHeadlessAgentRequest(state, request, "test-session"); + }); + auto resp = mcpCall(server, "tools/call", { + {"name", "whetstone_orchestrate_run_deterministic"}, + {"arguments", json::object()} + }); + assert(resp.contains("result")); + assert(resp["result"]["isError"].get() == false); + std::cout << "Test 9 PASSED: MCP run_deterministic tool call works\n"; + passed++; + } + + // Test 10: combined flow updates progress after external submit + { + auto state = makeState(); + state.reviewPolicy.defaultAction = "auto-approve"; + state.workflow->queue.enqueue(makeItem("d1", "getFast", "template")); + state.workflow->queue.enqueue(makeItem("x1", "buildGraph", "", "project")); + + rpc(state, "orchestrateRunDeterministic"); + auto blockers = rpc(state, "getBlockers"); + assert(blockers["result"]["count"].get() >= 1); + rpc(state, "submitExternalResult", { + {"itemId", "x1"}, + {"result", { + {"generatedCode", "def buildGraph(data):\n return data"}, + {"confidence", 0.88}, + {"reasoning", "llm output"}, + {"tokensGenerated", 24} + }} + }); + auto progress = rpc(state, "getProgress"); + assert(progress["result"]["completedItems"].get() == 2); + std::cout << "Test 10 PASSED: combined orchestrator flow tracked\n"; + passed++; + } + + // Test 11: submitExternalResult rejects non-agent worker types + { + auto state = makeState(); + WorkItem t = makeItem("t1", "getName", "template"); + transitionWorkItem(t, WI_READY); + transitionWorkItem(t, WI_ASSIGNED); + transitionWorkItem(t, WI_IN_PROGRESS); + state.workflow->queue.enqueue(t); + auto resp = rpc(state, "submitExternalResult", { + {"itemId", "t1"}, + {"result", {{"generatedCode", "return name"}}} + }); + assert(resp.contains("error")); + std::cout << "Test 11 PASSED: submitExternalResult enforces worker type\n"; + passed++; + } + + // Test 12: orchestrator RPC methods require active workflow + { + HeadlessEditorState state; + state.setAgentRole("test-session", AgentRole::Generator); + auto resp = rpc(state, "orchestrateAdvance"); + assert(resp.contains("error")); + std::cout << "Test 12 PASSED: workflow guards enforced\n"; + passed++; + } + + std::cout << "\nResults: " << passed << "/12\n"; + assert(passed == 12); + return 0; +} diff --git a/progress.md b/progress.md index 01e8f15..3f41991 100644 --- a/progress.md +++ b/progress.md @@ -2919,6 +2919,65 @@ worker metrics, blockers, and timeline entries. - `editor/src/WorkflowProgress.h` within header-size limit (`303` <= `600`) - `editor/tests/step381_test.cpp` within test-file size guidance (`196` lines) +### Step 382: Orchestrator RPC + MCP +**Status:** PASS (12/12 tests) + +Added orchestrator control RPC methods and MCP tool bindings so a client can +step/advance workflows, run deterministic items, inspect blockers/progress, and +submit external model output back into the workflow loop. + +**Files created:** +- `editor/src/HeadlessOrchestratorRPC.h` — extracted orchestrator RPC handling: + - `orchestrateStep` + - `orchestrateAdvance` + - `orchestrateRunDeterministic` + - `getBlockers` + - `getProgress` + - `submitExternalResult` + - event/blocker JSON serialization helpers and buffer-context collection +- `editor/tests/step382_test.cpp` — 12 tests covering: + 1. `orchestrateStep` advances one task + 2. `orchestrateAdvance` processes batch + 3. `orchestrateRunDeterministic` stops at blockers + 4. `getBlockers` reports human blockers + 5. `getProgress` snapshot shape + 6. `submitExternalResult` accepts LLM output and advances lifecycle + 7. Linter role can read progress but cannot orchestrate + 8. MCP tool registration for all 6 orchestrator tools + 9. MCP `whetstone_orchestrate_run_deterministic` callback wiring + 10. combined deterministic + external-submit + progress flow + 11. submit-result worker-type guard + 12. no-workflow guard behavior + +**Files modified:** +- `editor/src/HeadlessAgentRPCHandler.h` — delegated orchestrator-method + handling to `tryHandleHeadlessOrchestratorRPC(...)` +- `editor/src/HeadlessEditorState.h` — add `workflowProgress` state +- `editor/src/AgentPermissionPolicy.h` — permissions for new orchestrator methods + (`getProgress/getBlockers` read-only, orchestration/submit methods write) +- `editor/src/MCPServer.h` — register orchestrator MCP tools: + - `whetstone_orchestrate_step` + - `whetstone_orchestrate_advance` + - `whetstone_orchestrate_run_deterministic` + - `whetstone_get_blockers` + - `whetstone_get_progress` + - `whetstone_submit_result` +- `editor/CMakeLists.txt` — `step382_test` target + +**Verification run:** +- `step382_test` — PASS (12/12) new step coverage +- `step381_test` — PASS (12/12) regression coverage +- `step380_test` — PASS (12/12) regression coverage +- `step379_test` — PASS (12/12) regression coverage + +**Architecture gate check:** +- `editor/src/HeadlessOrchestratorRPC.h` within header-size limit (`287` <= `600`) +- `editor/src/WorkflowProgress.h` within header-size limit (`303` <= `600`) +- `editor/tests/step382_test.cpp` within test-file size guidance (`250` lines) +- Legacy oversized headers remain and should be split in follow-up: + - `editor/src/HeadlessAgentRPCHandler.h` (`2623` > `600`) + - `editor/src/MCPServer.h` (`1652` > `600`) + # Roadmap Planning — Sprints 12-25+ ## Status: Planning Complete (Sprints 12-19 detailed, 20-25 in roadmap.md)