From 159414e338bda8fdaa8645281e719175c3485a9f Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Thu, 27 Aug 2026 11:45:35 -0700 Subject: [PATCH 1/5] Desktop: run proposed plans after the turn drains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the engine change that stops executing plans inside the propose_plan tool call. propose_plan now only queues a plan; whoever hosts the chat turn is responsible for running it once the turn has drained. Without this call a plan proposed in the Desktop app would be silently accepted and then never executed — it still compiles, which is exactly what makes the omission dangerous. Adds AppendAssistantNote to IAiService so a completed plan's manifest can be recorded in history without giving the model an open turn to redo the work in. Bumps the MandoCode submodule to the engine's feature branch, since the host code depends on PlanHandoff.RunPendingPlanAsync and AIService.AppendAssistantNote. Re-point this at the merge commit once the engine PR lands. Known gap, deliberately not addressed here: when a user rejects a plan the CLI starts a follow-up turn so the model answers directly, and Desktop does not yet. Rejecting in Desktop currently ends the turn quietly. That needs UI work, which is out of scope for this change. Tests: 225 green. Co-Authored-By: Claude Opus 5 (1M context) --- MandoCode | 2 +- src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs | 1 + src/MandoCode.Desktop/Services/AiServiceAdapter.cs | 2 ++ src/MandoCode.Desktop/Services/IAiService.cs | 6 ++++++ src/MandoCode.Desktop/ViewModels/ChatController.cs | 6 ++++++ 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/MandoCode b/MandoCode index 3b5f667..81ba74a 160000 --- a/MandoCode +++ b/MandoCode @@ -1 +1 @@ -Subproject commit 3b5f6670f62fcbca5788c318e9956e8c0ada0178 +Subproject commit 81ba74a4c24eea5e9e282cbe2a5e452ac9871739 diff --git a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs index d968d0d..1f69f33 100644 --- a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs +++ b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs @@ -63,6 +63,7 @@ public event Action? OnFunctionCompleted { add { } remo public Task AttachMcpPluginsAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync() => throw new NotSupportedException(); public string? ExportHistoryJson() => throw new NotSupportedException(); + public void AppendAssistantNote(string text) => throw new NotSupportedException(); public int TryRestoreHistoryJson(string json) => throw new NotSupportedException(); public Task EnterLearnModeAsync() => throw new NotSupportedException(); public Task ClearHistoryAsync() => throw new NotSupportedException(); diff --git a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs index ea20bd5..e25025b 100644 --- a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs +++ b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs @@ -55,6 +55,8 @@ public Func>? OnCommandApprovalRequested public Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync() => _ai.ValidateModelAsync(); public IAsyncEnumerable ChatStreamAsync(string userMessage, CancellationToken cancellationToken = default) => _ai.ChatStreamAsync(userMessage, cancellationToken); public string? ExportHistoryJson() => _ai.ExportHistoryJson(); + + public void AppendAssistantNote(string text) => _ai.AppendAssistantNote(text); public int TryRestoreHistoryJson(string json) => _ai.TryRestoreHistoryJson(json); public Task EnterLearnModeAsync() => _ai.EnterLearnModeAsync(); public Task ClearHistoryAsync() => _ai.ClearHistoryAsync(); diff --git a/src/MandoCode.Desktop/Services/IAiService.cs b/src/MandoCode.Desktop/Services/IAiService.cs index 6181b59..6fd6857 100644 --- a/src/MandoCode.Desktop/Services/IAiService.cs +++ b/src/MandoCode.Desktop/Services/IAiService.cs @@ -37,6 +37,12 @@ public interface IAiService Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync(); IAsyncEnumerable ChatStreamAsync(string userMessage, CancellationToken cancellationToken = default); string? ExportHistoryJson(); + + /// + /// Appends an assistant message without calling the model. Used to record a completed plan's + /// manifest, which must land in history without giving the model an open turn to redo the work in. + /// + void AppendAssistantNote(string text); int TryRestoreHistoryJson(string json); Task EnterLearnModeAsync(); Task ClearHistoryAsync(); diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index 08f9796..5e3884d 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -479,6 +479,12 @@ private async Task ProcessDirectRequestAsync(string input) { var response = await _streamer.StreamAsync(input, token); if (!string.IsNullOrEmpty(response)) _lastAiResponse = response; + + // propose_plan now only queues a plan; the host runs it once the turn has drained, so + // the plan is a peer of the chat turn rather than something nested inside a tool call. + // Without this call a proposed plan would simply never execute. + var manifest = await _planHandoff.RunPendingPlanAsync(token); + if (!string.IsNullOrWhiteSpace(manifest)) _ai.AppendAssistantNote(manifest); } finally { From ded61a5f03ca3392323a6bc5250df545fb3bf1ad Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Thu, 27 Aug 2026 19:28:28 -0700 Subject: [PATCH 2/5] Desktop: let it run the workflow planner, and show the real version in the title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes, both about being able to tell what you are actually running. Desktop constructed TaskPlannerService directly, so `/config set planner workflow` had no effect here at all — the app silently stayed on the legacy engine no matter what the key said. It now goes through PlanRunnerSelector, the same as the CLI, so both engines can be A/B'd in the app rather than only from the terminal. TaskPlannerService is still injected for RequiresPlanning, which is a planning-trigger heuristic rather than part of IPlanRunner. The window title read UiUpdateCheckService.CurrentVersion, which is numeric-only and drops any prerelease tag, so a test build was indistinguishable from the release it was cut from. Adds DisplayVersion for that, sharing the engine's VersionLabel; CurrentVersion stays numeric because it feeds version comparison. Stamps this branch 0.15.0-plan-test. Only truthful now that Desktop can actually run the new planner — before this change the tag would have advertised a capability the binary did not have. Drop the suffix before release. Tests: 225 green. Co-Authored-By: Claude Opus 5 (1M context) --- MandoCode | 2 +- src/MandoCode.Desktop/MainWindow.xaml.cs | 2 +- src/MandoCode.Desktop/MandoCode.Desktop.csproj | 6 +++++- src/MandoCode.Desktop/Services/AgentSession.cs | 4 +++- .../Services/UiUpdateCheckService.cs | 16 ++++++++++++++++ .../ViewModels/ChatController.cs | 14 ++++++++++---- 6 files changed, 36 insertions(+), 8 deletions(-) diff --git a/MandoCode b/MandoCode index 81ba74a..0be4545 160000 --- a/MandoCode +++ b/MandoCode @@ -1 +1 @@ -Subproject commit 81ba74a4c24eea5e9e282cbe2a5e452ac9871739 +Subproject commit 0be4545d19ce68c8cb2b65e3c97a1fdc610b0e7b diff --git a/src/MandoCode.Desktop/MainWindow.xaml.cs b/src/MandoCode.Desktop/MainWindow.xaml.cs index ee40070..251e875 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml.cs +++ b/src/MandoCode.Desktop/MainWindow.xaml.cs @@ -60,7 +60,7 @@ private enum LeftPanel { None, Snapshots, History, Notes } public MainWindow() { InitializeComponent(); - Title = $"MandoCode Desktop v{UiUpdateCheckService.CurrentVersion}"; + Title = $"MandoCode Desktop {UiUpdateCheckService.DisplayVersion}"; ThemeManager.Initialize(Root); // ONE window-level subscription to the static ThemeChanged event. Chat tabs must not diff --git a/src/MandoCode.Desktop/MandoCode.Desktop.csproj b/src/MandoCode.Desktop/MandoCode.Desktop.csproj index 2265141..33fb382 100644 --- a/src/MandoCode.Desktop/MandoCode.Desktop.csproj +++ b/src/MandoCode.Desktop/MandoCode.Desktop.csproj @@ -14,7 +14,11 @@ true enable enable - 0.15.0 + + 0.15.0-plan-test Armando Fernandez (DevMando) MandoCode Desktop — the MandoCode AI coding agent with a native WinUI 3 interface. - 0.15.0-plan-test + 0.15.0 Armando Fernandez (DevMando) MandoCode Desktop — the MandoCode AI coding agent with a native WinUI 3 interface. + diff --git a/src/MandoCode.Desktop.Tests/PlanCardTests.cs b/src/MandoCode.Desktop.Tests/PlanCardTests.cs new file mode 100644 index 0000000..573b568 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/PlanCardTests.cs @@ -0,0 +1,31 @@ +using MandoCode.Desktop.Services; +using MandoCode.Models; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +public sealed class PlanCardTests +{ + [Fact] + public void PlanCard_ShowsExecutableInstructionsAndEscapesThem() + { + var plan = new TaskPlan + { + Steps = + [ + new TaskStep + { + StepNumber = 1, + Description = "Update the client", + Instruction = "Edit & run focused tests." + } + ] + }; + + var html = PlanCardHtml.Build(plan); + + Assert.Contains("What it will do", html); + Assert.Contains("Edit <ApiClient.cs> & run focused tests.", html); + Assert.DoesNotContain("Edit ", html); + } +} diff --git a/src/MandoCode.Desktop.Tests/PlanInstructionEditorTests.cs b/src/MandoCode.Desktop.Tests/PlanInstructionEditorTests.cs new file mode 100644 index 0000000..970ff17 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/PlanInstructionEditorTests.cs @@ -0,0 +1,60 @@ +using MandoCode.Desktop.ViewModels; +using MandoCode.Models; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +public sealed class PlanInstructionEditorTests +{ + [Fact] + public void Apply_ReplacesExecutableInstruction_AndRefreshesShortLabel() + { + var step = new TaskStep + { + Description = "Edit the API", + Instruction = "Change every API file." + }; + + var changed = PlanInstructionEditor.Apply(step, " Change only ApiClient.cs and run its tests. "); + + Assert.True(changed); + Assert.Equal("Change only ApiClient.cs and run its tests.", step.Instruction); + Assert.Equal(step.Instruction, step.Description); + } + + [Fact] + public void Apply_BlankInstruction_LeavesStepUnchanged() + { + var step = new TaskStep { Description = "Keep me", Instruction = "Keep this instruction." }; + + var changed = PlanInstructionEditor.Apply(step, " "); + + Assert.False(changed); + Assert.Equal("Keep this instruction.", step.Instruction); + Assert.Equal("Keep me", step.Description); + } + + [Fact] + public void Apply_LongInstruction_ReplacesStaleLabelWithBoundedCurrentLabel() + { + var step = new TaskStep { Description = "Update authentication", Instruction = "Old." }; + var longInstruction = new string('x', 80); + + PlanInstructionEditor.Apply(step, longInstruction); + + Assert.Equal(longInstruction, step.Instruction); + Assert.Equal(60, step.Description.Length); + Assert.EndsWith("...", step.Description); + } + + [Fact] + public void Apply_LongInstruction_CreatesBoundedLabelWhenMissing() + { + var step = new TaskStep { Description = "", Instruction = "Old." }; + + PlanInstructionEditor.Apply(step, new string('x', 80)); + + Assert.Equal(60, step.Description.Length); + Assert.EndsWith("...", step.Description); + } +} diff --git a/src/MandoCode.Desktop.Tests/PlanRevisionTests.cs b/src/MandoCode.Desktop.Tests/PlanRevisionTests.cs new file mode 100644 index 0000000..cf6f836 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/PlanRevisionTests.cs @@ -0,0 +1,78 @@ +using MandoCode.Models; +using MandoCode.Plugins; +using MandoCode.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +public sealed class PlanRevisionTests +{ + [Fact] + public void ApplyApproved_PreservesSettledPrefixAndFailedStepIdentity() + { + var plan = new TaskPlan + { + OriginalRequest = "ship it", + Status = TaskPlanStatus.InProgress, + Steps = + [ + Step(1, "done", TaskStepStatus.Completed, "result"), + Step(2, "failed", TaskStepStatus.Failed), + Step(3, "obsolete", TaskStepStatus.Pending) + ] + }; + var liveFailed = plan.Steps[1]; + var revision = new GeneratedPlan("revised", [ + new PlanStepProposal("repair", "repair carefully"), + new PlanStepProposal("verify", "run focused tests") + ]); + + var candidate = PlanRevision.CreateCandidate(plan, 2, revision); + PlanRevision.ApplyApproved(plan, 2, candidate); + + Assert.Same(liveFailed, plan.Steps[1]); + Assert.Equal(TaskStepStatus.Completed, plan.Steps[0].Status); + Assert.Equal("result", plan.Steps[0].Result); + Assert.Equal("repair", plan.Steps[1].Description); + Assert.Equal(TaskStepStatus.Pending, plan.Steps[1].Status); + Assert.Equal("verify", plan.Steps[2].Description); + Assert.Equal([1, 2, 3], plan.Steps.Select(step => step.StepNumber)); + } + + [Fact] + public void ApplyFollowing_PreservesEditedPrefixAndReplacesOnlyDependentSuffix() + { + var plan = new TaskPlan + { + OriginalRequest = "create then verify", + Steps = + [ + Step(1, "create beta", TaskStepStatus.Pending), + Step(2, "verify alpha", TaskStepStatus.Pending), + Step(3, "report alpha", TaskStepStatus.Pending) + ] + }; + var edited = plan.Steps[0]; + var revision = new GeneratedPlan("updated", [ + new PlanStepProposal("verify beta", "read and verify beta"), + new PlanStepProposal("report beta", "report the beta result") + ]); + + var candidate = PlanRevision.CreateFollowingCandidate(plan, 1, revision); + PlanRevision.ApplyFollowing(plan, 1, candidate); + + Assert.Same(edited, plan.Steps[0]); + Assert.Equal(["create beta", "verify beta", "report beta"], + plan.Steps.Select(step => step.Description)); + Assert.Equal([1, 2, 3], plan.Steps.Select(step => step.StepNumber)); + } + + private static TaskStep Step(int number, string description, TaskStepStatus status, string? result = null) => new() + { + StepNumber = number, + Description = description, + Instruction = description + " instruction", + Status = status, + Result = result + }; +} diff --git a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs index 1f69f33..5cbbb3f 100644 --- a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs +++ b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs @@ -62,6 +62,7 @@ public event Action? OnFunctionCompleted { add { } remo public Task RefreshSettingsAsync(MandoCodeConfig config) => throw new NotSupportedException(); public Task AttachMcpPluginsAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync() => throw new NotSupportedException(); + public Task GeneratePlanAsync(string request, string? revisionContext = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public string? ExportHistoryJson() => throw new NotSupportedException(); public void AppendAssistantNote(string text) => throw new NotSupportedException(); public int TryRestoreHistoryJson(string json) => throw new NotSupportedException(); diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css index fe065d7..15b4d3c 100644 --- a/src/MandoCode.Desktop/Assets/web/transcript/transcript.css +++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.css @@ -289,6 +289,17 @@ .panel-header { padding: 6px 12px; font-weight: 600; border-bottom: 1px solid var(--border); font-family: "Cascadia Code", Consolas, monospace; font-size: 13px; } .panel-footer { padding: 4px 12px 8px 12px; color: var(--dim); font-size: 12px; } + .checkpoint-body { padding: 10px 12px 7px; } + .checkpoint-goal { color: var(--fg); font-weight: 600; overflow-wrap: anywhere; } + .checkpoint-progress { margin-top: 4px; color: var(--dim); font-size: 12px; } + .checkpoint-actions { display: flex; align-items: center; gap: 8px; padding: 2px 12px 11px; } + .checkpoint-btn { cursor: pointer; border: 1px solid var(--border); border-radius: 7px; + padding: 4px 12px; color: var(--fg); background: var(--bg); font: 600 12px "Segoe UI", sans-serif; } + .checkpoint-resume { color: var(--green); border-color: color-mix(in srgb, var(--green) 65%, var(--border)); } + .checkpoint-discard { color: var(--red); } + .checkpoint-btn:hover:not(:disabled) { border-color: var(--accent); } + .checkpoint-btn:disabled { cursor: default; opacity: .5; } + .checkpoint-state { color: var(--dim); font-size: 12px; } pre.cmd, pre.cmd-out, pre.diff, pre.mono-block, pre.raw { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: "Cascadia Code", "Cascadia Mono", Consolas, monospace; font-size: 13px; diff --git a/src/MandoCode.Desktop/Assets/web/transcript/transcript.js b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js index ac4f634..e70c087 100644 --- a/src/MandoCode.Desktop/Assets/web/transcript/transcript.js +++ b/src/MandoCode.Desktop/Assets/web/transcript/transcript.js @@ -415,6 +415,22 @@ window.chrome.webview.postMessage('undo-file:' + undo.getAttribute('data-file')); }); + // Unfinished-plan card actions. The host owns the actual checkpoint operation; this only + // provides immediate click feedback and prevents a double-submit while the command starts. + document.addEventListener('click', function (e) { + const resume = e.target.closest('.checkpoint-resume'); + const discard = e.target.closest('.checkpoint-discard'); + if (!resume && !discard) return; + const card = (resume || discard).closest('.checkpoint-card'); + if (!card || card.dataset.submitted) return; + card.dataset.submitted = '1'; + card.querySelectorAll('.checkpoint-btn').forEach(function (button) { button.disabled = true; }); + const state = card.querySelector('.checkpoint-state'); + if (state) state.textContent = resume ? 'Resuming…' : 'Discarding…'; + if (window.chrome && window.chrome.webview) + window.chrome.webview.postMessage(resume ? 'plan-resume' : 'plan-discard'); + }); + // --- drag hand-off: Chromium owns drags over the transcript surface, so XAML never sees // them. On dragenter we alert the host, which mounts its drop overlay over this WebView; // the OS then retargets the drag (and the drop, with real file paths) to that overlay. diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Approvals.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Approvals.cs index 461ddd6..8f99c1c 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Approvals.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Approvals.cs @@ -168,7 +168,12 @@ private void SetApprovalCardSize(bool instructionMode) } } - public Task ShowInstructionInputAsync(string prompt, string placeholder = "", bool allowCancel = false, CancellationToken ct = default) + public Task ShowInstructionInputAsync( + string prompt, + string placeholder = "", + string initialValue = "", + bool allowCancel = false, + CancellationToken ct = default) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _instructionTcs = tcs; @@ -187,7 +192,7 @@ public Task ShowInstructionInputAsync(string prompt, string placeholder ApprovalBodyScroll.Visibility = Visibility.Collapsed; ApprovalButtons.Visibility = Visibility.Collapsed; - InstructionBox.Text = ""; + InstructionBox.Text = initialValue; InstructionBox.PlaceholderText = string.IsNullOrEmpty(placeholder) ? "Type your answer and press Enter" : placeholder; @@ -196,6 +201,7 @@ public Task ShowInstructionInputAsync(string prompt, string placeholder SetApprovalCardSize(instructionMode: true); ShowApprovalOverlay(); InstructionBox.Focus(FocusState.Programmatic); + if (!string.IsNullOrEmpty(initialValue)) InstructionBox.SelectAll(); }); return tcs.Task; diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs index d4899db..0216f75 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs @@ -198,7 +198,7 @@ public async Task InitializeAsync() // File-path links in transcript cards post "open-file:" messages // (see TranscriptHtmlBuilder.FileLink). - core.WebMessageReceived += (_, e) => + core.WebMessageReceived += (sender, e) => { string? msg = null; try { msg = e.TryGetWebMessageAsString(); } catch { /* non-string message — not ours */ } @@ -214,6 +214,10 @@ public async Task InitializeAsync() ShowDropOverlay(); // a drag crossed onto the WebView surface — see DropOverlay else if (msg != null && msg.StartsWith("undo-file:", StringComparison.Ordinal)) UndoFileFromCard(msg["undo-file:".Length..]); // interactive diff card's Undo chip + else if (msg == "plan-resume") + _ = _controller.SubmitAsync("/plan-resume"); + else if (msg == "plan-discard") + _ = _controller.SubmitAsync("/plan-discard"); }; // Serve bundled web assets (highlight.js) to the transcript document. diff --git a/src/MandoCode.Desktop/Services/AgentSession.cs b/src/MandoCode.Desktop/Services/AgentSession.cs index ea8c4cc..1523ff0 100644 --- a/src/MandoCode.Desktop/Services/AgentSession.cs +++ b/src/MandoCode.Desktop/Services/AgentSession.cs @@ -104,7 +104,15 @@ public AgentSession( Ai = new AIService(ProjectRoot, Config, Tokens, PlanHandoff, Skills, mcpManager, McpGate, spinner); Planner = new TaskPlannerService(Ai, Config); - PlanRunners = new PlanRunnerSelector(Config, Planner, new AiServicePlanStepExecutor(Ai)); + // PersistKey is the durable tab identity. Including it in the checkpoint key prevents two + // agents working in the same project from overwriting each other's unfinished plans. + PlanRunners = new PlanRunnerSelector( + Config, + Planner, + new AiServicePlanStepExecutor(Ai), + PlanHandoff, + ProjectRoot, + PersistKey); var ignoreDirs = new HashSet(MandoCodeConfig.DefaultIgnoreDirectories); foreach (var dir in Config.IgnoreDirectories) ignoreDirs.Add(dir); diff --git a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs index e25025b..56b3320 100644 --- a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs +++ b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs @@ -54,7 +54,10 @@ public Func>? OnCommandApprovalRequested public Task AttachMcpPluginsAsync(CancellationToken cancellationToken = default) => _ai.AttachMcpPluginsAsync(cancellationToken); public Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync() => _ai.ValidateModelAsync(); public IAsyncEnumerable ChatStreamAsync(string userMessage, CancellationToken cancellationToken = default) => _ai.ChatStreamAsync(userMessage, cancellationToken); + public Task GeneratePlanAsync(string request, string? revisionContext = null, CancellationToken cancellationToken = default) => + _ai.GeneratePlanAsync(request, revisionContext, cancellationToken); public string? ExportHistoryJson() => _ai.ExportHistoryJson(); + public void SetRequestContext(string? request) => _ai.SetRequestContext(request); public void AppendAssistantNote(string text) => _ai.AppendAssistantNote(text); public int TryRestoreHistoryJson(string json) => _ai.TryRestoreHistoryJson(json); diff --git a/src/MandoCode.Desktop/Services/ApprovalModels.cs b/src/MandoCode.Desktop/Services/ApprovalModels.cs index 8cf5e0e..c7d9479 100644 --- a/src/MandoCode.Desktop/Services/ApprovalModels.cs +++ b/src/MandoCode.Desktop/Services/ApprovalModels.cs @@ -65,10 +65,16 @@ public interface IApprovalUi /// /// Free-text input — used by the "Provide new instructions" approval path and by the - /// wizard flows. When is true a Cancel button (and Esc) + /// wizard flows. pre-populates the editor for true edit + /// scenarios. When is true a Cancel button (and Esc) /// resolves to . /// - Task ShowInstructionInputAsync(string prompt, string placeholder = "", bool allowCancel = false, CancellationToken ct = default); + Task ShowInstructionInputAsync( + string prompt, + string placeholder = "", + string initialValue = "", + bool allowCancel = false, + CancellationToken ct = default); } /// Sentinel values passed back through the string-based prompt contract. diff --git a/src/MandoCode.Desktop/Services/IAiService.cs b/src/MandoCode.Desktop/Services/IAiService.cs index 6fd6857..2521e55 100644 --- a/src/MandoCode.Desktop/Services/IAiService.cs +++ b/src/MandoCode.Desktop/Services/IAiService.cs @@ -36,8 +36,15 @@ public interface IAiService Task AttachMcpPluginsAsync(CancellationToken cancellationToken = default); Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync(); IAsyncEnumerable ChatStreamAsync(string userMessage, CancellationToken cancellationToken = default); + Task GeneratePlanAsync( + string request, + string? revisionContext = null, + CancellationToken cancellationToken = default); string? ExportHistoryJson(); + /// Restores the authoritative request used by isolated plan-step histories. + void SetRequestContext(string? request) { } + /// /// Appends an assistant message without calling the model. Used to record a completed plan's /// manifest, which must land in history without giving the model an open turn to redo the work in. diff --git a/src/MandoCode.Desktop/Services/PlanCardHtml.cs b/src/MandoCode.Desktop/Services/PlanCardHtml.cs new file mode 100644 index 0000000..a6e8898 --- /dev/null +++ b/src/MandoCode.Desktop/Services/PlanCardHtml.cs @@ -0,0 +1,23 @@ +using System.Net; +using System.Text; +using MandoCode.Models; + +namespace MandoCode.Desktop.Services; + +/// Builds the reviewable, escaped transcript card for a proposed plan. +public static class PlanCardHtml +{ + public static string Build(TaskPlan plan) + { + static string Escape(string? text) => WebUtility.HtmlEncode(text ?? ""); + + var sb = new StringBuilder(); + sb.Append("
Proposed plan
"); + sb.Append(""); + foreach (var step in plan.Steps) + sb.Append($"" + + $""); + sb.Append("
StepDescriptionWhat it will do
{step.StepNumber}{Escape(step.Description)}{Escape(step.Instruction)}
"); + return sb.ToString(); + } +} diff --git a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs index 292b5bb..f1c50de 100644 --- a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs +++ b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs @@ -1,6 +1,8 @@ using System.Net; using System.Text; using MandoCode.Models; +using MandoCode.Services; +using MandoCode.Desktop.ViewModels; using Markdig; namespace MandoCode.Desktop.Services; @@ -71,6 +73,7 @@ public string AssistantCard(string markdown, string? speaker = null) => /// capture stays dumb and faithful; the judgment lives at replay.
public static bool IsEphemeralStatus(string blockHtml) => blockHtml.StartsWith("
Rebuilding the AI session for the new project…<", StringComparison.Ordinal) @@ -254,16 +257,9 @@ public string OperationCard(OperationDisplayEvent op) return sb.ToString(); } - public string PlanCard(TaskPlan plan) - { - var sb = new StringBuilder(); - sb.Append("
Proposed plan
"); - sb.Append(""); - foreach (var step in plan.Steps) - sb.Append($""); - sb.Append("
StepDescription
{step.StepNumber}{E(step.Description)}
"); - return sb.ToString(); - } + public string PlanCard(TaskPlan plan) => PlanCardHtml.Build(plan); + + public string CheckpointCard(PlanRunState saved) => CheckpointCardHtml.Build(saved); public string StepStarted(int current, int total, string description) => $"
Step {current}/{total}: {E(description)}
"; diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.Plans.cs b/src/MandoCode.Desktop/ViewModels/ChatController.Plans.cs new file mode 100644 index 0000000..5f079e9 --- /dev/null +++ b/src/MandoCode.Desktop/ViewModels/ChatController.Plans.cs @@ -0,0 +1,227 @@ +using MandoCode.Models; +using MandoCode.Services; + +namespace MandoCode.Desktop.ViewModels; + +public sealed partial class ChatController +{ + /// Reports an unfinished plan without starting it. Resume always requires a command. + private void ShowUnfinishedPlanNotice() + { + if (!_planRunners.SupportsResume) return; + + var saved = _planRunners.FindResumable(out var refusal); + if (refusal != null) + { + _transcript.Append(_html.Warn(refusal)); + _transcript.Append(_html.Dim("Use /plan-discard to forget the incompatible checkpoint.")); + return; + } + + if (saved == null) return; + + var outstanding = PlanCheckpointStore.OutstandingSteps(saved); + if (outstanding == 0) return; + + _transcript.Append(_html.CheckpointCard(saved)); + _transcript.Append(_html.Dim("Resume or discard it here, or use /plan to inspect every step.")); + } + + private async Task HandlePlanCommandAsync(string action) + { + if (!string.IsNullOrWhiteSpace(action) && action is not "resume" and not "discard") + { + await ForcePlanAsync(action); + return; + } + + if (!_planRunners.SupportsResume) + { + _transcript.Append(_html.Warn("Plan resume requires the workflow planner.")); + _transcript.Append(_html.Dim("Enable it for this agent with: /config set planner workflow")); + return; + } + + if (action == "discard") + { + _planRunners.DiscardResumable(); + _transcript.Append(_html.Dim("Saved plan discarded for this agent.")); + return; + } + + var saved = _planRunners.FindResumable(out var refusal); + if (refusal != null) + { + _transcript.Append(_html.Warn(refusal)); + _transcript.Append(_html.Dim("Use /plan-discard to forget it.")); + return; + } + + if (saved == null) + { + _transcript.Append(_html.Dim("No unfinished plan for this agent.")); + return; + } + + var outstanding = PlanCheckpointStore.OutstandingSteps(saved); + var done = saved.Steps.Count - outstanding; + + if (action != "resume") + { + _transcript.Append(_html.Info($"Unfinished plan: {PlanGoalPreview(saved.Goal)}")); + _transcript.Append(_html.Dim($"{done} of {saved.Steps.Count} steps settled.")); + _transcript.Append(_html.CheckpointCard(saved)); + _transcript.Append(_html.PlanCard(PlanCheckpointStore.ToPlan(saved))); + _transcript.Append(_html.Mono(string.Join("\n", saved.Steps.Select(step => + { + var marker = step.Status switch + { + TaskStepStatus.Completed => "done", + TaskStepStatus.Skipped => "skipped", + TaskStepStatus.Failed => "retry", + TaskStepStatus.InProgress => "interrupted", + _ => "pending" + }; + return $"[{marker}] Step {step.Number}: {step.Description}"; + })))); + _transcript.Append(_html.Dim("/plan-resume to continue, /plan-discard to forget it.")); + return; + } + + if (!IsConnected || ModelError) + { + _transcript.Append(_html.Warn("Connect to a working model before resuming this plan.")); + _transcript.Append(_html.Dim("Use /retry, /model, or Settings, then run /plan-resume again.")); + return; + } + + await ResumePlanAsync(saved); + } + + /// + /// Implements the deterministic /plan <goal> path. Proposal generation is isolated + /// from the normal agent and can only call propose_plan; the resulting plan then enters + /// the exact same review/edit/approval flow as a heuristic proposal. + /// + private async Task ForcePlanAsync(string goal) + { + if (!IsConnected || ModelError) + { + _transcript.Append(_html.Warn("Connect to a working model before creating a plan.")); + _transcript.Append(_html.Dim("Use /retry, /model, or Settings, then try /plan again.")); + return; + } + + _requestCts = new CancellationTokenSource(); + var token = _requestCts.Token; + _busy.Start("Creating plan..."); + + try + { + _deferredPlans.Outcome = DeferredPlanOutcome.None; + var proposal = await _ai.GeneratePlanAsync(goal, cancellationToken: token); + var result = await _planHandoff.ProcessAsync( + proposal.Goal, proposal.Steps, token, originalRequest: goal); + + if (_deferredPlans.Outcome == DeferredPlanOutcome.Executed && + !string.IsNullOrWhiteSpace(result)) + { + _ai.AppendAssistantNote(result); + } + else if (_deferredPlans.Outcome == DeferredPlanOutcome.Rejected && + !token.IsCancellationRequested) + { + // "One-shot it" still means what the approval button says even though this + // command has no outer model turn waiting to receive the rejection result. + var response = await _streamer.StreamAsync( + goal + "\n\n" + DeferredPlanCompletion.RejectionFollowUpPrompt, token); + if (!string.IsNullOrEmpty(response)) _lastAiResponse = response; + _planHandoff.ClearPendingProposal(); + } + } + catch (OperationCanceledException) + { + _transcript.Append(_html.Warn("Planning cancelled.")); + } + catch (Exception ex) + { + _transcript.Append(_html.Error($"Could not create plan: {ex.Message}")); + } + finally + { + _busy.Reset(); + var oldCts = Interlocked.Exchange(ref _requestCts, null); + oldCts?.Dispose(); + StateChanged?.Invoke(); + } + } + + private async Task ResumePlanAsync(PlanRunState saved) + { + var plan = PlanCheckpointStore.ToPlan(saved); + var runner = _planRunners.Current as WorkflowPlanRunner; + if (runner == null) + { + _transcript.Append(_html.Error("The selected planner cannot resume workflow checkpoints.")); + return; + } + + _transcript.Append(_html.Success( + $"Resuming plan with {PlanCheckpointStore.OutstandingSteps(saved)} step(s) left...")); + _ai.SetRequestContext(saved.Goal); + + _requestCts = new CancellationTokenSource(); + var token = _requestCts.Token; + PlanProgressChanged?.Invoke(plan.CompletedStepsCount, plan.Steps.Count, true); + _busy.Start("Resuming plan..."); + + try + { + using var execution = _planHandoff.BeginResumedExecution(saved.FileOperations); + var ui = _approvals.Ui + ?? throw new InvalidOperationException("Approval UI not attached yet."); + + await foreach (var progressEvent in runner.ResumeAsync(plan, saved, token)) + await HandleProgressEventAsync(progressEvent, plan, ui, token); + + var manifest = PlanHandoff.BuildManifest(plan, _planHandoff.FileOperations); + _ai.AppendAssistantNote(manifest); + + if (plan.Status == TaskPlanStatus.Completed) + _transcript.Append(_html.Success("Resumed plan completed.")); + else if (plan.Status == TaskPlanStatus.CompletedWithIssues) + _transcript.Append(_html.Warn("Resumed plan completed with skipped or failed steps.")); + else if (plan.Status == TaskPlanStatus.Cancelled) + _transcript.Append(_html.Warn("Resumed plan was cancelled; its checkpoint remains available.")); + else + _transcript.Append(_html.Error("Resumed plan finished with unresolved failures.")); + + if (!string.IsNullOrWhiteSpace(plan.ExecutionSummary)) + _transcript.Append(_html.Dim(plan.ExecutionSummary)); + } + catch (OperationCanceledException) + { + _transcript.Append(_html.Warn("Plan resume cancelled; completed progress remains checkpointed.")); + } + catch (Exception ex) + { + _transcript.Append(_html.Error($"Could not resume plan: {ex.Message}")); + _transcript.Append(_html.Dim("The checkpoint was kept. Use /plan-resume to try again.")); + } + finally + { + PlanProgressChanged?.Invoke(plan.CompletedStepsCount, plan.Steps.Count, false); + _busy.Reset(); + var oldCts = Interlocked.Exchange(ref _requestCts, null); + oldCts?.Dispose(); + StateChanged?.Invoke(); + } + } + + private static string PlanGoalPreview(string goal) + { + var oneLine = string.Join(" ", goal.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + .Trim(); + return oneLine.Length <= 180 ? oneLine : oneLine[..177] + "..."; + } +} diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index 06b64f4..7e09fe5 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -21,6 +21,7 @@ public sealed partial class ChatController private readonly MandoCodeConfig _config; private readonly TokenTrackingService _tokenTracker; private readonly PlanHandoff _planHandoff; + private readonly DeferredPlanCompletion _deferredPlans; private readonly TaskPlannerService _taskPlanner; // Which engine runs a plan is re-read per plan, so `planner` can be flipped // mid-session. _taskPlanner is still needed for RequiresPlanning, which is a @@ -173,6 +174,7 @@ public ChatController( _config = config; _tokenTracker = tokenTracker; _planHandoff = planHandoff; + _deferredPlans = new DeferredPlanCompletion(_planHandoff); _taskPlanner = taskPlanner; _planRunners = planRunners; _mcpManager = mcpManager; @@ -306,6 +308,7 @@ public async Task InitializeAsync() _transcript.Append(_html.StatusChip(ModelName, "ready", "ok")); } + ShowUnfinishedPlanNotice(); StateChanged?.Invoke(); // Fire-and-forget update check against MandoCode.Desktop's own GitHub releases — @@ -452,7 +455,7 @@ public async Task SubmitAsync(string input) _pendingReactions.Clear(); _pendingWorkspaceNotes.Clear(); - await ProcessDirectRequestAsync(processedInput); + await ProcessDirectRequestAsync(processedInput, input); } finally { @@ -472,7 +475,7 @@ public void CancelActiveRequest() // Direct AI request (port of ProcessDirectRequestAsync) // ============================================================ - private async Task ProcessDirectRequestAsync(string input) + private async Task ProcessDirectRequestAsync(string input, string? originalRequest = null) { // Reset the per-request operation tracking the function-call event handlers read. _recentReadCount = 0; @@ -492,6 +495,11 @@ private async Task ProcessDirectRequestAsync(string input) var response = await _streamer.StreamAsync(input, token); if (!string.IsNullOrEmpty(response)) _lastAiResponse = response; + // AIService sees the expanded/preambled model input. Before the queued plan is taken, + // replace that fallback with the user's actual text so checkpoints preserve paths and + // intent without persisting attachment dumps or internal planning nudges. + _planHandoff.SetRequestContext(originalRequest ?? input); + // propose_plan now only queues a plan; the host runs it once the turn has drained, so // the plan is a peer of the chat turn rather than something nested inside a tool call. // Without this call a proposed plan would simply never execute. @@ -501,8 +509,11 @@ private async Task ProcessDirectRequestAsync(string input) } else { - var manifest = await _planHandoff.RunPendingPlanAsync(token); - if (!string.IsNullOrWhiteSpace(manifest)) _ai.AppendAssistantNote(manifest); + var completion = await _deferredPlans.CompleteAsync(token, _streamer.StreamAsync); + if (!string.IsNullOrEmpty(completion.FollowUpResponse)) + _lastAiResponse = completion.FollowUpResponse; + if (!string.IsNullOrWhiteSpace(completion.Manifest)) + _ai.AppendAssistantNote(completion.Manifest); } } catch (OperationCanceledException) @@ -694,10 +705,17 @@ private void OnFunctionCompleted(FunctionExecutionResult result) // ============================================================ private const string ExecutePlanLabel = "Execute plan"; + private const string EditPlanStepLabel = "Edit a step"; private const string RejectPlanLabel = "One-shot it"; private const string CancelRequestLabel = "Cancel request"; + private const string RetryStepLabel = "Retry this step"; + private const string EditAndRetryStepLabel = "Edit instruction and retry"; + private const string ReviseRemainingPlanLabel = "Revise the remaining plan"; + private const string UseRevisedPlanLabel = "Use revised plan"; + private const string KeepCurrentPlanLabel = "Keep current plan and skip this step"; private const string SkipStepLabel = "Skip this step and continue"; private const string CancelPlanLabel = "Cancel the plan"; + private const string BackLabel = "Back"; private async Task HandleProposedPlanAsync(TaskPlan plan, CancellationToken ct) { @@ -709,29 +727,37 @@ private async Task HandleProposedPlanAsync(TaskPlan plan, CancellationTo using (await _promptGate.AcquireAsync(ct)) { _busy.Stop(); - _transcript.Append(_html.PlanCard(plan)); - - choice = await ui.ShowApprovalAsync(new ApprovalRequest + while (true) { - Title = "The assistant proposes this plan. What would you like to do?", - // Bottom bar, not the centered modal — the plan card above stays readable. - BottomBar = true, - ToastSummary = "Wants to run a proposed plan", - Options = new[] + _transcript.Append(_html.PlanCard(plan)); + + choice = await ui.ShowApprovalAsync(new ApprovalRequest { - new ApprovalOption(ExecutePlanLabel, ApprovalOptionKind.Proceed), - new ApprovalOption(RejectPlanLabel, ApprovalOptionKind.Redirect, - Description: "Skip the step-by-step plan — the model attempts the whole request in one shot."), - // Destructive (red) so the hard "stop" reads differently from "one-shot it". - new ApprovalOption(CancelRequestLabel, ApprovalOptionKind.Destructive) - } - }, ct); + Title = "The assistant proposes this plan. What would you like to do?", + // Bottom bar, not the centered modal — the plan card above stays readable. + BottomBar = true, + ToastSummary = "Wants to run a proposed plan", + Options = new[] + { + new ApprovalOption(ExecutePlanLabel, ApprovalOptionKind.Proceed), + new ApprovalOption(EditPlanStepLabel, ApprovalOptionKind.Redirect), + new ApprovalOption(RejectPlanLabel, ApprovalOptionKind.Redirect, + Description: "Skip the step-by-step plan — the model attempts the whole request in one shot."), + // Destructive (red) so the hard "stop" reads differently from "one-shot it". + new ApprovalOption(CancelRequestLabel, ApprovalOptionKind.Destructive) + } + }, ct); - _transcript.Append(_html.UserEcho(choice)); + _transcript.Append(_html.UserEcho(choice)); + if (choice != EditPlanStepLabel) break; + + await EditPlanStepAsync(plan, ui, ct); + } } if (choice == CancelRequestLabel) { + _deferredPlans.Outcome = DeferredPlanOutcome.Cancelled; _transcript.Append(_html.Dim("Request cancelled — stopping here.")); // Cancel the request token so the turn mechanically unwinds — the return // string alone is a polite request small models ignore (see App.razor). @@ -741,11 +767,13 @@ private async Task HandleProposedPlanAsync(TaskPlan plan, CancellationTo if (choice == RejectPlanLabel) { + _deferredPlans.Outcome = DeferredPlanOutcome.Rejected; _transcript.Append(_html.Dim("Plan skipped — one-shotting your request.")); return "User declined the step-by-step plan and wants you to one-shot it: attempt the full " + "request in a single pass. Do not call propose_plan again."; } + _deferredPlans.Outcome = DeferredPlanOutcome.Executed; _transcript.Append(_html.Success("Executing plan...")); PlanProgressChanged?.Invoke(0, plan.Steps.Count, true); @@ -753,7 +781,7 @@ private async Task HandleProposedPlanAsync(TaskPlan plan, CancellationTo { await foreach (var progressEvent in _planRunners.Current.ExecutePlanAsync(plan, ct)) { - await HandleProgressEventAsync(progressEvent, plan, ui); + await HandleProgressEventAsync(progressEvent, plan, ui, ct); } } catch (OperationCanceledException) @@ -768,6 +796,8 @@ private async Task HandleProposedPlanAsync(TaskPlan plan, CancellationTo if (plan.Status == TaskPlanStatus.Completed) _transcript.Append(_html.Success("Plan completed successfully!")); + else if (plan.Status == TaskPlanStatus.CompletedWithIssues) + _transcript.Append(_html.Warn("Plan completed with skipped or failed steps.")); else if (plan.Status == TaskPlanStatus.Cancelled) _transcript.Append(_html.Warn("Plan was cancelled.")); else @@ -782,7 +812,114 @@ private async Task HandleProposedPlanAsync(TaskPlan plan, CancellationTo "Do NOT create more files, call more tools, or propose another plan."; } - private async Task HandleProgressEventAsync(TaskProgressEvent progressEvent, TaskPlan plan, IApprovalUi ui) + private async Task EditPlanStepAsync( + TaskPlan plan, + IApprovalUi ui, + CancellationToken ct, + int minimumStepNumber = 1, + bool reviseFollowingSteps = true) + { + var choices = plan.Steps + .Where(step => step.StepNumber >= minimumStepNumber) + .ToDictionary( + step => $"Step {step.StepNumber}: {step.Description}", + step => step); + + var selected = await ui.ShowApprovalAsync(new ApprovalRequest + { + Title = "Which plan step do you want to edit?", + Options = choices.Keys + .Select(label => new ApprovalOption(label, ApprovalOptionKind.Proceed)) + .Append(new ApprovalOption(BackLabel, ApprovalOptionKind.Redirect)) + .ToArray() + }, ct); + + if (selected == BackLabel || !choices.TryGetValue(selected, out var step)) + return false; + + if (!await EditPlanStepAsync(step, ui, ct)) + return false; + + if (reviseFollowingSteps && plan.Steps.Any(candidate => candidate.StepNumber > step.StepNumber)) + await ReviseFollowingStepsAfterEditAsync(plan, step, ct); + + return true; + } + + private async Task EditPlanStepAsync(TaskStep step, IApprovalUi ui, CancellationToken ct) + { + var revised = await ui.ShowInstructionInputAsync( + $"Edit step {step.StepNumber}\nModify the existing instruction below.", + "Enter the step instruction", + initialValue: step.Instruction, + allowCancel: true, + ct: ct); + + if (revised == ApprovalSignals.Cancelled || !PlanInstructionEditor.Apply(step, revised)) + { + _transcript.Append(_html.Dim($"Step {step.StepNumber} left unchanged.")); + return false; + } + + _transcript.Append(_html.Success($"Step {step.StepNumber} updated.")); + return true; + } + + private async Task ReviseFollowingStepsAfterEditAsync( + TaskPlan plan, + TaskStep editedStep, + CancellationToken ct) + { + _busy.Start("Updating dependent steps..."); + _transcript.Append(_html.Dim( + $"Updating steps after {editedStep.StepNumber} so they stay consistent with your edit...")); + + try + { + var earlier = plan.Steps + .Where(step => step.StepNumber < editedStep.StepNumber) + .Select(step => $"Step {step.StepNumber}: {step.Description}\nInstruction: {step.Instruction}"); + var later = plan.Steps + .Where(step => step.StepNumber > editedStep.StepNumber) + .Select(step => $"Step {step.StepNumber}: {step.Description}\nInstruction: {step.Instruction}"); + var context = + $"The user edited step {editedStep.StepNumber}. Their edited instruction is authoritative and must not be changed:\n" + + $"{editedStep.Instruction}\n\n" + + "Earlier steps that must remain unchanged:\n" + string.Join("\n\n", earlier) + "\n\n" + + "Return only replacement steps that come after the edited step. Update paths, values, and verification " + + "expectations so they are consistent with the edit. State each replacement as the actual work to execute; " + + "never say to replace, update, or revise a plan step, and never refer to old step numbers. " + + "Do not repeat earlier or edited steps.\n\n" + + "Current later steps to replace:\n" + string.Join("\n\n", later); + + var revision = await _ai.GeneratePlanAsync(plan.OriginalRequest, context, ct); + if (revision.Steps.Length == 1 && + revision.Steps[0].description == "Complete the requested goal" && + revision.Steps[0].instruction == plan.OriginalRequest.Trim()) + throw new InvalidOperationException("The model did not return a usable dependent-step revision."); + + var candidate = PlanRevision.CreateFollowingCandidate(plan, editedStep.StepNumber, revision); + PlanRevision.ApplyFollowing(plan, editedStep.StepNumber, candidate); + _transcript.Append(_html.Success( + "Dependent steps updated. Review the complete plan before executing it.")); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _transcript.Append(_html.Warn( + $"Could not update dependent steps automatically: {ex.Message} Review the later steps manually.")); + } + finally + { + _busy.Stop(); + } + } + + private async Task HandleProgressEventAsync( + TaskProgressEvent progressEvent, + TaskPlan plan, + IApprovalUi ui, + CancellationToken ct) { switch (progressEvent.ProgressType) { @@ -809,27 +946,74 @@ private async Task HandleProgressEventAsync(TaskProgressEvent progressEvent, Tas _busy.Stop(); _transcript.Append(_html.Error($"Step {progressEvent.CurrentStep} failed: {progressEvent.Message ?? "Unknown error"}")); + // Cancellation is already a terminal decision, not another failure choice. Asking + // retry/skip/cancel here would make a diff-approval cancel look ineffective. + if (plan.Status == TaskPlanStatus.Cancelled || ct.IsCancellationRequested) + break; + // Must resolve before enumeration continues — same semantics as the // CLI's blocking prompt: SkipStep/CancelPlan mutate plan.Status before // ExecutePlanAsync's next iteration reconciles. - var failChoice = await ui.ShowApprovalAsync(new ApprovalRequest + var failedStep = plan.Steps.FirstOrDefault(s => s.StepNumber == progressEvent.CurrentStep); + string failChoice; + + // Retry is a workflow-engine capability: its triage node re-dispatches a failed + // step when the UI changes its state back to Pending. The legacy foreach runner + // has already advanced and therefore cannot truthfully offer it. + while (true) { - Title = "How would you like to proceed?", - Subtitle = $"Step {progressEvent.CurrentStep} failed", - Options = new[] + var options = new List(); + if (_planRunners.UsingWorkflowEngine) { - new ApprovalOption(SkipStepLabel, ApprovalOptionKind.Redirect), - new ApprovalOption(CancelPlanLabel, ApprovalOptionKind.Destructive) + options.Add(new ApprovalOption(RetryStepLabel, ApprovalOptionKind.Proceed)); + options.Add(new ApprovalOption(EditAndRetryStepLabel, ApprovalOptionKind.Redirect)); + options.Add(new ApprovalOption(ReviseRemainingPlanLabel, ApprovalOptionKind.Redirect, + Description: "Generate a replacement for this step and all remaining steps, then review it.")); } - }); + options.Add(new ApprovalOption(SkipStepLabel, ApprovalOptionKind.Redirect)); + options.Add(new ApprovalOption(CancelPlanLabel, ApprovalOptionKind.Destructive)); + + failChoice = await ui.ShowApprovalAsync(new ApprovalRequest + { + Title = "How would you like to proceed?", + Subtitle = $"Step {progressEvent.CurrentStep} failed", + Options = options + }, ct); + + if (failChoice != EditAndRetryStepLabel || failedStep == null) + break; + + if (await EditPlanStepAsync(failedStep, ui, ct)) + { + failedStep.Status = TaskStepStatus.Pending; + break; + } + } if (failChoice == CancelPlanLabel) { _planRunners.Current.CancelPlan(plan); } + else if (failChoice == ReviseRemainingPlanLabel && failedStep != null) + { + var revisionDecision = await ReplanAfterFailureAsync( + plan, failedStep, progressEvent.Message ?? "Unknown error", ui, ct); + if (revisionDecision == ReplanDecision.Cancel) + _planRunners.Current.CancelPlan(plan); + else if (revisionDecision == ReplanDecision.KeepCurrent) + _planRunners.Current.SkipStep(plan, failedStep); + } + else if (failChoice == RetryStepLabel && failedStep != null) + { + failedStep.Status = TaskStepStatus.Pending; + _transcript.Append(_html.Dim($"Retrying step {progressEvent.CurrentStep}...")); + } + else if (failChoice == EditAndRetryStepLabel && failedStep != null) + { + _transcript.Append(_html.Dim($"Retrying step {progressEvent.CurrentStep} with the revised instruction...")); + } else { - var failedStep = plan.Steps.FirstOrDefault(s => s.StepNumber == progressEvent.CurrentStep); if (failedStep != null) _planRunners.Current.SkipStep(plan, failedStep); } _busy.Start(); @@ -842,6 +1026,85 @@ private async Task HandleProgressEventAsync(TaskProgressEvent progressEvent, Tas } } + private enum ReplanDecision { Applied, KeepCurrent, Cancel } + + private async Task ReplanAfterFailureAsync( + TaskPlan plan, + TaskStep failedStep, + string error, + IApprovalUi ui, + CancellationToken ct) + { + _busy.Start("Revising plan..."); + _transcript.Append(_html.Dim("Revising the unfinished portion of the plan...")); + + TaskPlan candidate; + try + { + var completed = plan.Steps + .Where(step => step.StepNumber < failedStep.StepNumber) + .Select(step => $"Step {step.StepNumber} [{step.Status}]: {step.Description}\nResult: {step.Result ?? "(none)"}"); + var remaining = plan.Steps + .Where(step => step.StepNumber >= failedStep.StepNumber) + .Select(step => $"Step {step.StepNumber}: {step.Description}\nInstruction: {step.Instruction}"); + var context = + $"Failed step {failedStep.StepNumber}: {failedStep.Description}\n" + + $"Failure: {error}\n\n" + + "Settled earlier steps:\n" + string.Join("\n\n", completed) + "\n\n" + + "Return final executable steps, not instructions about changing the plan. Never say to replace, update, " + + "or revise a step, and never refer to old step numbers.\n\n" + + "Current failed and remaining steps to replace:\n" + string.Join("\n\n", remaining); + + var revision = await _ai.GeneratePlanAsync(plan.OriginalRequest, context, ct); + candidate = PlanRevision.CreateCandidate(plan, failedStep.StepNumber, revision); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _transcript.Append(_html.Error($"Could not revise the plan: {ex.Message}")); + _transcript.Append(_html.Dim("Keeping the current plan and skipping the failed step.")); + return ReplanDecision.KeepCurrent; + } + finally + { + _busy.Stop(); + } + + while (true) + { + _transcript.Append(_html.PlanCard(candidate)); + var choice = await ui.ShowApprovalAsync(new ApprovalRequest + { + Title = "Review the revised remaining plan", + Subtitle = $"Steps before {failedStep.StepNumber} are already settled and will not run again.", + BottomBar = true, + ToastSummary = "Revised plan is ready", + Options = new[] + { + new ApprovalOption(UseRevisedPlanLabel, ApprovalOptionKind.Proceed), + new ApprovalOption(EditPlanStepLabel, ApprovalOptionKind.Redirect), + new ApprovalOption(KeepCurrentPlanLabel, ApprovalOptionKind.Redirect), + new ApprovalOption(CancelPlanLabel, ApprovalOptionKind.Destructive) + } + }, ct); + _transcript.Append(_html.UserEcho(choice)); + + if (choice == EditPlanStepLabel) + { + await EditPlanStepAsync(candidate, ui, ct, failedStep.StepNumber, reviseFollowingSteps: false); + continue; + } + if (choice == CancelPlanLabel) return ReplanDecision.Cancel; + if (choice == KeepCurrentPlanLabel) return ReplanDecision.KeepCurrent; + + PlanRevision.ApplyApproved(plan, failedStep.StepNumber, candidate); + _transcript.Append(_html.Success( + $"Revised plan approved — resuming at step {failedStep.StepNumber} of {plan.Steps.Count}.")); + PlanProgressChanged?.Invoke(failedStep.StepNumber - 1, plan.Steps.Count, true); + return ReplanDecision.Applied; + } + } + // ============================================================ // Slash commands (port of the RunInteractiveLoopAsync dispatch ladder) // ============================================================ @@ -902,6 +1165,18 @@ private async Task DispatchCommandAsync(string input) await HandleRetryCommandAsync(); return; + case "plan": + await HandlePlanCommandAsync(rawArgs); + return; + + case "plan-resume": + await HandlePlanCommandAsync("resume"); + return; + + case "plan-discard": + await HandlePlanCommandAsync("discard"); + return; + case "copy": if (string.IsNullOrEmpty(_lastAiResponse)) _transcript.Append(_html.Warn("Nothing to copy — no AI response yet.")); @@ -965,6 +1240,9 @@ private void ShowHelp() ("/model", "Quick switch — pick a different model (/model skips the picker)"), ("/config", "Adjust settings — guided wizard (/config set inline)"), ("/retry", "Retry Ollama connection"), + ("/plan ", "Force a step-by-step plan; without a goal, inspect an unfinished plan"), + ("/plan-resume", "Continue this agent's unfinished plan"), + ("/plan-discard", "Forget this agent's unfinished plan"), ("/learn", "Learn about LLMs and local AI models"), ("/music", "Play coding music (also /music-lofi, /music-synthwave for a specific genre)"), ("/music-stop", "Stop music playback"), diff --git a/src/MandoCode.Desktop/ViewModels/CheckpointCardHtml.cs b/src/MandoCode.Desktop/ViewModels/CheckpointCardHtml.cs new file mode 100644 index 0000000..046259e --- /dev/null +++ b/src/MandoCode.Desktop/ViewModels/CheckpointCardHtml.cs @@ -0,0 +1,31 @@ +using System.Net; +using MandoCode.Services; + +namespace MandoCode.Desktop.ViewModels; + +/// Builds the native, actionable checkpoint card shown in the Desktop transcript. +public static class CheckpointCardHtml +{ + public static string Build(PlanRunState saved) + { + var outstanding = PlanCheckpointStore.OutstandingSteps(saved); + var done = saved.Steps.Count - outstanding; + var goal = OneLine(saved.Goal); + return "
" + + "
Unfinished plan
" + + $"
{E(goal)}
" + + $"
{done} of {saved.Steps.Count} steps settled · {outstanding} remaining
" + + "
" + + "" + + "" + + "
"; + } + + private static string OneLine(string value) + { + var text = string.Join(" ", value.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)).Trim(); + return text.Length <= 180 ? text : text[..177] + "..."; + } + + private static string E(string value) => WebUtility.HtmlEncode(value); +} diff --git a/src/MandoCode.Desktop/ViewModels/DeferredPlanCompletion.cs b/src/MandoCode.Desktop/ViewModels/DeferredPlanCompletion.cs new file mode 100644 index 0000000..95ef368 --- /dev/null +++ b/src/MandoCode.Desktop/ViewModels/DeferredPlanCompletion.cs @@ -0,0 +1,89 @@ +using MandoCode.Services; + +namespace MandoCode.Desktop.ViewModels; + +/// The user's decision for a plan proposed during the current chat turn. +public enum DeferredPlanOutcome +{ + None, + Executed, + Rejected, + Cancelled +} + +/// The history additions produced after a deferred proposal is resolved. +public sealed record DeferredPlanCompletionResult(string? Manifest, string? FollowUpResponse) +{ + public static readonly DeferredPlanCompletionResult Empty = new(null, null); +} + +/// +/// Completes a plan queued by after the model's proposing turn drains. +/// Rejection is special: the user still expects the original request to be answered, so it gets +/// exactly one direct follow-up turn. Any plan proposed by that follow-up is discarded to prevent +/// a reject/propose loop. +/// +public sealed class DeferredPlanCompletion +{ + public const string RejectionFollowUpPrompt = + "[system: the user reviewed your proposed plan and chose to skip stepwise " + + "execution. Answer their original request directly now. Do not call propose_plan.]"; + + private readonly PlanHandoff _planHandoff; + private int _followUpDepth; + + public DeferredPlanCompletion(PlanHandoff planHandoff) + { + _planHandoff = planHandoff; + } + + /// Set by the plan approval callback while the pending proposal is being resolved. + public DeferredPlanOutcome Outcome { get; set; } + + public async Task CompleteAsync( + CancellationToken cancellationToken, + Func> runFollowUpAsync) + { + if (!_planHandoff.HasPendingProposal) + return DeferredPlanCompletionResult.Empty; + + // A direct follow-up is already answering a rejected plan. Running another proposal here + // would contradict the user's choice and can create an unbounded proposal loop. + if (_followUpDepth > 0) + { + _planHandoff.ClearPendingProposal(); + return DeferredPlanCompletionResult.Empty; + } + + // Proposals belong only to the turn that created them. Never carry a cancelled proposal + // into a later, unrelated request. + if (cancellationToken.IsCancellationRequested) + { + _planHandoff.ClearPendingProposal(); + return DeferredPlanCompletionResult.Empty; + } + + Outcome = DeferredPlanOutcome.None; + var manifest = await _planHandoff.RunPendingPlanAsync(cancellationToken); + + if (Outcome == DeferredPlanOutcome.Cancelled) + return DeferredPlanCompletionResult.Empty; + + if (Outcome != DeferredPlanOutcome.Rejected) + return new DeferredPlanCompletionResult(manifest, null); + + _followUpDepth++; + try + { + var response = await runFollowUpAsync(RejectionFollowUpPrompt, cancellationToken); + return new DeferredPlanCompletionResult(null, response); + } + finally + { + // The rejected-plan answer is one-shot even if the model ignored the instruction and + // called propose_plan again. Do not let that proposal leak into the next user turn. + _planHandoff.ClearPendingProposal(); + _followUpDepth--; + } + } +} diff --git a/src/MandoCode.Desktop/ViewModels/PlanInstructionEditor.cs b/src/MandoCode.Desktop/ViewModels/PlanInstructionEditor.cs new file mode 100644 index 0000000..8ea58ac --- /dev/null +++ b/src/MandoCode.Desktop/ViewModels/PlanInstructionEditor.cs @@ -0,0 +1,21 @@ +using MandoCode.Models; + +namespace MandoCode.Desktop.ViewModels; + +/// Applies user review edits to the instruction that a plan step will execute. +public static class PlanInstructionEditor +{ + public static bool Apply(TaskStep step, string? revisedInstruction) + { + if (string.IsNullOrWhiteSpace(revisedInstruction)) + return false; + + var revised = revisedInstruction.Trim(); + step.Instruction = revised; + // The description is a UI label, but a stale label makes the review card misleading. + // Always derive it from the instruction the user actually approved. + step.Description = revised.Length > 60 ? revised[..57] + "..." : revised; + + return true; + } +}