diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bf25a4..fe0d6d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,32 @@ submodule. ## [Unreleased] -A new engine underneath, the same app on top. Desktop moves onto engine generation 0.15, whose -headline change is a foundation swap rather than a feature: the orchestration layer that routes -a message to a model, runs tools, and streams the reply back moved off Semantic Kernel and onto -Microsoft's Agent Framework. Same models, same tools, same approval prompts, same context -snapshots — there is no new button to find. Desktop's version follows the engine generation, so -it moves 0.14.1 → 0.15.0. +A new engine underneath, and a planner users can actually steer. Desktop moves onto engine +generation 0.15 and Microsoft's Agent Framework, then uses that foundation to make plans durable: +review the work before it starts, edit a step, recover after a restart, and change course when a +failure proves the remaining plan wrong. The workflow planner stays opt-in for this release while +the long-running model soak and local-model token measurements finish. Desktop's version follows +the engine generation, so it moves 0.14.1 → 0.15.0. + +### Added +- **An Unfinished Plan card appears when an agent has checkpointed work.** Resume continues at the + first unsettled step; Discard forgets the saved run. The card reflects current checkpoint state + rather than transcript history, so an obsolete Resume button cannot come back after restart. +- **`/plan ` forces a reviewable plan.** This gives short but cross-cutting work the same + planning path as a long request, without depending on a message-length heuristic. A one-step plan + can still be sent straight through with One-shot it. +- **Failed work can produce a revised remaining plan.** Completed steps stay settled, the proposed + replacement is shown for review, and execution resumes only after approval. ### Changed +- **Plan review shows what every step will actually do.** Selecting Edit a step opens a prefilled + editor. When an early step changes a file name, value, or expectation, Desktop refreshes only the + dependent steps and shows the complete plan again before execution. +- **Step failures offer clear decisions.** Retry, revise the remaining plan, skip, and cancel are + separate choices. Retried instructions stay attached to the failed step rather than becoming a + new chat request. +- **Plan progress and recovery use the engine's durable workflow cursor.** Restarting does not rerun + completed steps, and the agent is briefed with the restored plan context before it continues. - **The engine now runs on Microsoft Agent Framework.** Semantic Kernel is gone from the codebase entirely; chat history moved onto the new framework's own types. The new path was built alongside the old one and verified against real models before the cutover, and the old @@ -32,8 +50,22 @@ it moves 0.14.1 → 0.15.0. use the same Microsoft.Extensions.AI client the engine standardized on. Same prompts, same temperatures, same behavior — but Desktop no longer depends on a framework the engine has removed. Snapshot recaps and note replies are the surfaces to sanity-check. -- **Pinned engine commit: `3b5f667`** (engine 0.15.0). The exact engine each Desktop release - ships is recorded by the `MandoCode` submodule. +- **Engine PR review pin: `e058399`** (engine 0.15.0). This will be replaced by the CLI PR's merge + commit before the Desktop PR lands; the final release pin remains the exact shipped engine. + +### Fixed +- **Partial completion is no longer called a full success.** A plan that reaches the end after + skipped or failed work says how many steps completed and reports “completed with issues.” +- **Cancelling a plan no longer produces a second, contradictory error path.** Desktop stops at the + user's decision instead of showing retry choices or reporting an unexpected failure afterward. +- **Approval and recovery cards stay out of persisted transcript history.** They are live controls, + not conversation messages, so stale actions are not replayed into a restored session. + +### Test coverage +239 Desktop tests pass. New host-level coverage exercises deferred plan execution, instruction +editing, dependent-step revision, checkpoint cards, Resume/Discard actions, semantic step outcomes, +and truthful completion status. The same workflows were also exercised with real models, including +closing the process between steps and resuming from the saved cursor. ## [0.14.1] — 2026-07-28 diff --git a/MandoCode b/MandoCode index 3b5f667..e058399 160000 --- a/MandoCode +++ b/MandoCode @@ -1 +1 @@ -Subproject commit 3b5f6670f62fcbca5788c318e9956e8c0ada0178 +Subproject commit e05839930a6645569eb18d5e6a2b79f4a84c04f9 diff --git a/src/MandoCode.Desktop.Tests/CheckpointCardHtmlTests.cs b/src/MandoCode.Desktop.Tests/CheckpointCardHtmlTests.cs new file mode 100644 index 0000000..e62a6b3 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/CheckpointCardHtmlTests.cs @@ -0,0 +1,30 @@ +using MandoCode.Desktop.ViewModels; +using MandoCode.Models; +using MandoCode.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +public sealed class CheckpointCardHtmlTests +{ + [Fact] + public void Build_ShowsProgressActionsAndEscapesGoal() + { + var state = new PlanRunState + { + Goal = "Fix & tests", + Steps = + [ + new PlanStepState { Number = 1, Description = "done", Status = TaskStepStatus.Completed }, + new PlanStepState { Number = 2, Description = "left", Status = TaskStepStatus.Pending } + ] + }; + + var html = CheckpointCardHtml.Build(state); + + Assert.Contains("Fix <planner> & tests", html); + Assert.Contains("1 of 2 steps settled", html); + Assert.Contains("checkpoint-resume", html); + Assert.Contains("checkpoint-discard", html); + } +} diff --git a/src/MandoCode.Desktop.Tests/DeferredPlanCompletionTests.cs b/src/MandoCode.Desktop.Tests/DeferredPlanCompletionTests.cs new file mode 100644 index 0000000..bbe18ac --- /dev/null +++ b/src/MandoCode.Desktop.Tests/DeferredPlanCompletionTests.cs @@ -0,0 +1,165 @@ +using MandoCode.Desktop.ViewModels; +using MandoCode.Models; +using MandoCode.Plugins; +using MandoCode.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +public sealed class DeferredPlanCompletionTests +{ + private static PlanStepProposal[] Steps(params string[] descriptions) + => [.. descriptions.Select(d => new PlanStepProposal(d, $"Do {d}."))]; + + [Fact] + public async Task NoPendingProposal_DoesNothing() + { + var completion = new DeferredPlanCompletion(new PlanHandoff()); + var followUps = 0; + + var result = await completion.CompleteAsync( + CancellationToken.None, + (_, _) => { followUps++; return Task.FromResult("unexpected"); }); + + Assert.Equal(DeferredPlanCompletionResult.Empty, result); + Assert.Equal(0, followUps); + } + + [Fact] + public async Task CancelledTurn_DropsProposalWithoutRunningIt() + { + var planRuns = 0; + var handoff = new PlanHandoff + { + OnPlanRequested = (_, _) => + { + planRuns++; + return Task.FromResult("unexpected"); + } + }; + handoff.SetPendingProposal("goal", Steps("one")); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var result = await new DeferredPlanCompletion(handoff).CompleteAsync( + cts.Token, + (_, _) => Task.FromResult("unexpected")); + + Assert.Equal(DeferredPlanCompletionResult.Empty, result); + Assert.Equal(0, planRuns); + Assert.False(handoff.HasPendingProposal); + } + + [Fact] + public async Task RejectedPlan_RunsExactlyOneDirectFollowUp() + { + var handoff = new PlanHandoff(); + DeferredPlanCompletion? completion = null; + handoff.OnPlanRequested = (_, _) => + { + completion!.Outcome = DeferredPlanOutcome.Rejected; + return Task.FromResult("internal rejection directive"); + }; + completion = new DeferredPlanCompletion(handoff); + handoff.SetPendingProposal("goal", Steps("one")); + var prompts = new List(); + + var result = await completion.CompleteAsync( + CancellationToken.None, + (prompt, _) => + { + prompts.Add(prompt); + return Task.FromResult("direct answer"); + }); + + Assert.Null(result.Manifest); + Assert.Equal("direct answer", result.FollowUpResponse); + Assert.Equal([DeferredPlanCompletion.RejectionFollowUpPrompt], prompts); + } + + [Fact] + public async Task RejectionFollowUp_CannotQueueOrRunAnotherPlan() + { + var planRuns = 0; + var nestedFollowUps = 0; + var handoff = new PlanHandoff(); + DeferredPlanCompletion? completion = null; + handoff.OnPlanRequested = (_, _) => + { + planRuns++; + completion!.Outcome = DeferredPlanOutcome.Rejected; + return Task.FromResult("rejected"); + }; + completion = new DeferredPlanCompletion(handoff); + handoff.SetPendingProposal("first", Steps("one")); + + await completion.CompleteAsync( + CancellationToken.None, + async (_, ct) => + { + // Simulate a model ignoring the direct-answer instruction and proposing again. + handoff.SetPendingProposal("second", Steps("two")); + var nested = await completion.CompleteAsync( + ct, + (_, _) => + { + nestedFollowUps++; + return Task.FromResult("unexpected"); + }); + Assert.Equal(DeferredPlanCompletionResult.Empty, nested); + return "direct answer"; + }); + + Assert.Equal(1, planRuns); + Assert.Equal(0, nestedFollowUps); + Assert.False(handoff.HasPendingProposal); + } + + [Fact] + public async Task CancelledPlan_DoesNotAppendItsInternalDirective() + { + var handoff = new PlanHandoff(); + DeferredPlanCompletion? completion = null; + handoff.OnPlanRequested = (_, _) => + { + completion!.Outcome = DeferredPlanOutcome.Cancelled; + return Task.FromResult("internal cancellation directive"); + }; + completion = new DeferredPlanCompletion(handoff); + handoff.SetPendingProposal("goal", Steps("one")); + + var result = await completion.CompleteAsync( + CancellationToken.None, + (_, _) => Task.FromResult("unexpected")); + + Assert.Equal(DeferredPlanCompletionResult.Empty, result); + } + + [Fact] + public async Task ExecutedPlan_ReturnsManifestWithoutFollowUp() + { + var followUps = 0; + var handoff = new PlanHandoff(); + DeferredPlanCompletion? completion = null; + handoff.OnPlanRequested = (plan, _) => + { + completion!.Outcome = DeferredPlanOutcome.Executed; + plan.Steps[0].Status = TaskStepStatus.Completed; + plan.Steps[0].Result = "Implemented and verified."; + plan.Status = TaskPlanStatus.Completed; + return Task.FromResult("complete"); + }; + completion = new DeferredPlanCompletion(handoff); + handoff.SetPendingProposal("goal", Steps("one")); + + var result = await completion.CompleteAsync( + CancellationToken.None, + (_, _) => { followUps++; return Task.FromResult("unexpected"); }); + + Assert.NotNull(result.Manifest); + Assert.Contains("1 of 1 steps completed", result.Manifest); + Assert.Contains("Implemented and verified.", result.Manifest); + Assert.Null(result.FollowUpResponse); + Assert.Equal(0, followUps); + } +} diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index 69498a6..0ddc05b 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -74,12 +74,16 @@ + + + + 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 d968d0d..5cbbb3f 100644 --- a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs +++ b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs @@ -62,7 +62,9 @@ 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(); public Task EnterLearnModeAsync() => throw new NotSupportedException(); public Task ClearHistoryAsync() => 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/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/Services/AgentSession.cs b/src/MandoCode.Desktop/Services/AgentSession.cs index f30d3be..1523ff0 100644 --- a/src/MandoCode.Desktop/Services/AgentSession.cs +++ b/src/MandoCode.Desktop/Services/AgentSession.cs @@ -59,6 +59,7 @@ public string Title public McpApprovalGate McpGate { get; } public AIService Ai { get; } public TaskPlannerService Planner { get; } + public PlanRunnerSelector PlanRunners { get; } public FileAutocompleteProvider FileProvider { get; } public BusyStateService Busy { get; } public TranscriptWriter Transcript { get; } @@ -103,6 +104,15 @@ public AgentSession( Ai = new AIService(ProjectRoot, Config, Tokens, PlanHandoff, Skills, mcpManager, McpGate, spinner); Planner = new TaskPlannerService(Ai, Config); + // 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); @@ -124,7 +134,7 @@ public AgentSession( Shell = new ShellRunner(ProjectRoot, Transcript, html); Controller = new ChatController( - new AiServiceAdapter(Ai), Config, Tokens, PlanHandoff, Planner, + new AiServiceAdapter(Ai), Config, Tokens, PlanHandoff, Planner, PlanRunners, mcpManager, McpGate, Skills, FileProvider, ProjectRoot, music, updateCheck, Approvals, Transcript, html, Busy, Shell, PromptGate, configs, mcp, Snapshots); diff --git a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs index ea20bd5..56b3320 100644 --- a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs +++ b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs @@ -54,7 +54,12 @@ 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); public Task EnterLearnModeAsync() => _ai.EnterLearnModeAsync(); public Task ClearHistoryAsync() => _ai.ClearHistoryAsync(); 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 6181b59..2521e55 100644 --- a/src/MandoCode.Desktop/Services/IAiService.cs +++ b/src/MandoCode.Desktop/Services/IAiService.cs @@ -36,7 +36,20 @@ 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. + /// + void AppendAssistantNote(string text); int TryRestoreHistoryJson(string json); Task EnterLearnModeAsync(); Task ClearHistoryAsync(); 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/Services/UiUpdateCheckService.cs b/src/MandoCode.Desktop/Services/UiUpdateCheckService.cs index b3118e4..1d541d4 100644 --- a/src/MandoCode.Desktop/Services/UiUpdateCheckService.cs +++ b/src/MandoCode.Desktop/Services/UiUpdateCheckService.cs @@ -30,6 +30,10 @@ public sealed class UiUpdateCheckService Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MandoCode.Desktop", "update-check.json"); + /// + /// Numeric version, for comparing against published releases. Never carries a prerelease tag — + /// see for what a human should be shown. + /// public static string CurrentVersion { get @@ -39,6 +43,18 @@ public static string CurrentVersion } } + /// + /// Version for display, including any prerelease tag (e.g. "v0.16.0-rc.1"). + /// + /// + /// Kept separate from , which feeds version comparison and must stay + /// numeric. The window title used the numeric one, so a tagged test build was indistinguishable + /// from the release it was cut from — exactly the confusion that makes a stale binary hard to + /// spot. Shares the CLI's formatting so both products label builds the same way. + /// + public static string DisplayVersion => + MandoCode.Services.VersionLabel.ForAssembly(Assembly.GetExecutingAssembly()); + public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) { try 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 08f9796..7e09fe5 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -21,7 +21,12 @@ 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 + // planning-trigger heuristic rather than part of IPlanRunner. + private readonly PlanRunnerSelector _planRunners; private readonly McpClientManager _mcpManager; private readonly McpApprovalGate _mcpGate; private readonly SkillLoader _skills; @@ -147,6 +152,7 @@ public ChatController( TokenTrackingService tokenTracker, PlanHandoff planHandoff, TaskPlannerService taskPlanner, + PlanRunnerSelector planRunners, McpClientManager mcpManager, McpApprovalGate mcpGate, SkillLoader skills, @@ -168,7 +174,9 @@ public ChatController( _config = config; _tokenTracker = tokenTracker; _planHandoff = planHandoff; + _deferredPlans = new DeferredPlanCompletion(_planHandoff); _taskPlanner = taskPlanner; + _planRunners = planRunners; _mcpManager = mcpManager; _mcpGate = mcpGate; _skills = skills; @@ -300,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 — @@ -446,7 +455,7 @@ public async Task SubmitAsync(string input) _pendingReactions.Clear(); _pendingWorkspaceNotes.Clear(); - await ProcessDirectRequestAsync(processedInput); + await ProcessDirectRequestAsync(processedInput, input); } finally { @@ -466,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; @@ -475,10 +484,44 @@ private async Task ProcessDirectRequestAsync(string input) _requestCts = new CancellationTokenSource(); var token = _requestCts.Token; + + // A proposal belongs to the turn that produced it. Without this, a plan proposed during a + // turn the user then cancelled would sit in the slot and execute at the end of some later, + // unrelated turn. + _planHandoff.ClearPendingProposal(); + try { 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. + if (token.IsCancellationRequested) + { + _planHandoff.ClearPendingProposal(); + } + else + { + 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) + { + // The streamer already reports cancellation to the transcript. Letting this escape + // reached the caller's catch-all and reported it a second time as + // "Unexpected error: A task was canceled." — observed live. + _planHandoff.ClearPendingProposal(); } finally { @@ -662,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) { @@ -677,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). @@ -709,25 +767,27 @@ 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); try { - await foreach (var progressEvent in _taskPlanner.ExecutePlanAsync(plan, ct)) + await foreach (var progressEvent in _planRunners.Current.ExecutePlanAsync(plan, ct)) { - await HandleProgressEventAsync(progressEvent, plan, ui); + await HandleProgressEventAsync(progressEvent, plan, ui, ct); } } catch (OperationCanceledException) { _busy.Stop(); - _taskPlanner.CancelPlan(plan); + _planRunners.Current.CancelPlan(plan); } finally { @@ -736,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 @@ -750,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) { @@ -777,28 +946,75 @@ 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) + { + 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)) { - new ApprovalOption(SkipStepLabel, ApprovalOptionKind.Redirect), - new ApprovalOption(CancelPlanLabel, ApprovalOptionKind.Destructive) + failedStep.Status = TaskStepStatus.Pending; + break; } - }); + } if (failChoice == CancelPlanLabel) { - _taskPlanner.CancelPlan(plan); + _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) _taskPlanner.SkipStep(plan, failedStep); + if (failedStep != null) _planRunners.Current.SkipStep(plan, failedStep); } _busy.Start(); break; @@ -810,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) // ============================================================ @@ -870,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.")); @@ -933,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; + } +}