Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <goal>` 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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion MandoCode
Submodule MandoCode updated 58 files
+43 −7 docs/CHANGELOG.md
+695 −48 src/MandoCode/Components/App.razor
+5 −3 src/MandoCode/Components/Banner.razor
+19 −8 src/MandoCode/Components/PromptInput.razor
+16 −1 src/MandoCode/MandoCode.csproj
+25 −0 src/MandoCode/Models/ConfigKeySetter.cs
+9 −0 src/MandoCode/Models/GeneratedPlan.cs
+25 −0 src/MandoCode/Models/MandoCodeConfig.cs
+3 −0 src/MandoCode/Models/SlashCommands.cs
+16 −12 src/MandoCode/Models/SystemPrompts.cs
+3 −0 src/MandoCode/Models/TaskPlan.cs
+3 −1 src/MandoCode/Models/TaskProgressEvent.cs
+13 −0 src/MandoCode/Program.cs
+320 −48 src/MandoCode/Services/Ai/AIService.cs
+40 −21 src/MandoCode/Services/Ai/AgentFunctionMiddleware.cs
+23 −28 src/MandoCode/Services/Ai/InvocationScope.cs
+156 −9 src/MandoCode/Services/Ai/PlanHandoff.cs
+36 −0 src/MandoCode/Services/Ai/Planning/IPlanRunner.cs
+61 −0 src/MandoCode/Services/Ai/Planning/IPlanStepExecutor.cs
+120 −0 src/MandoCode/Services/Ai/Planning/PlanCheckpointEnvelope.cs
+163 −0 src/MandoCode/Services/Ai/Planning/PlanCheckpointStore.cs
+92 −0 src/MandoCode/Services/Ai/Planning/PlanExecutorIds.cs
+95 −0 src/MandoCode/Services/Ai/Planning/PlanRevision.cs
+103 −0 src/MandoCode/Services/Ai/Planning/PlanRunState.cs
+87 −0 src/MandoCode/Services/Ai/Planning/PlanRunnerSelector.cs
+54 −0 src/MandoCode/Services/Ai/Planning/PlanStepReport.cs
+343 −0 src/MandoCode/Services/Ai/Planning/PlanWorkflowExecutors.cs
+58 −0 src/MandoCode/Services/Ai/Planning/PlanWorkflowMessages.cs
+185 −0 src/MandoCode/Services/Ai/Planning/WorkflowPlanRunner.cs
+78 −0 src/MandoCode/Services/Ai/StepNarration.cs
+18 −1 src/MandoCode/Services/Ai/StreamBuffering.cs
+29 −11 src/MandoCode/Services/Ai/TaskPlannerService.cs
+1 −1 src/MandoCode/Services/Input/InputStateMachine.cs
+4 −1 src/MandoCode/Services/Input/InstructionPromptCoordinator.cs
+42 −0 src/MandoCode/Services/VersionLabel.cs
+84 −2 src/MandoCode/docs/TaskPlanner.md
+88 −14 tests/MandoCode.Tests/AgentFunctionMiddlewareLifecycleTests.cs
+27 −16 tests/MandoCode.Tests/AgentFunctionMiddlewarePendingPlanGateTests.cs
+1 −0 tests/MandoCode.Tests/InputStateMachineTests.cs
+36 −0 tests/MandoCode.Tests/InstructionPromptCoordinatorTests.cs
+115 −0 tests/MandoCode.Tests/PlanCheckpointEnvelopeTests.cs
+120 −0 tests/MandoCode.Tests/PlanCheckpointStoreTests.cs
+86 −0 tests/MandoCode.Tests/PlanExecutorIdsTests.cs
+40 −0 tests/MandoCode.Tests/PlanHandoffTests.cs
+84 −0 tests/MandoCode.Tests/PlanProposalSlotTests.cs
+100 −0 tests/MandoCode.Tests/PlanResumeContextTests.cs
+78 −0 tests/MandoCode.Tests/PlanRevisionTests.cs
+118 −0 tests/MandoCode.Tests/PlanRunStateTests.cs
+171 −0 tests/MandoCode.Tests/PlanRunnerBehaviorTests.cs
+46 −0 tests/MandoCode.Tests/PlanStepReportTests.cs
+121 −0 tests/MandoCode.Tests/PlanStepRetryTests.cs
+114 −0 tests/MandoCode.Tests/PlanWorkflowTopologyTests.cs
+109 −0 tests/MandoCode.Tests/PlannerEngineConfigTests.cs
+48 −0 tests/MandoCode.Tests/ScriptedPlanStepExecutor.cs
+113 −0 tests/MandoCode.Tests/StepNarrationTests.cs
+1 −1 tests/MandoCode.Tests/StreamBufferingTests.cs
+24 −0 tests/MandoCode.Tests/SystemPromptsTests.cs
+65 −0 tests/MandoCode.Tests/VersionLabelTests.cs
30 changes: 30 additions & 0 deletions src/MandoCode.Desktop.Tests/CheckpointCardHtmlTests.cs
Original file line number Diff line number Diff line change
@@ -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 <planner> & 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 &lt;planner&gt; &amp; tests", html);
Assert.Contains("1 of 2 steps settled", html);
Assert.Contains("checkpoint-resume", html);
Assert.Contains("checkpoint-discard", html);
}
}
165 changes: 165 additions & 0 deletions src/MandoCode.Desktop.Tests/DeferredPlanCompletionTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>();

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);
}
}
4 changes: 4 additions & 0 deletions src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,16 @@
<Compile Include="..\MandoCode.Desktop\Services\AgentCallsigns.cs" Link="src\AgentCallsigns.cs" />
<Compile Include="..\MandoCode.Desktop\Services\HistorySummarizer.cs" Link="src\HistorySummarizer.cs" />
<Compile Include="..\MandoCode.Desktop\ViewModels\RequestPreambleComposer.cs" Link="src\RequestPreambleComposer.cs" />
<Compile Include="..\MandoCode.Desktop\ViewModels\DeferredPlanCompletion.cs" Link="src\DeferredPlanCompletion.cs" />
<Compile Include="..\MandoCode.Desktop\ViewModels\PlanInstructionEditor.cs" Link="src\PlanInstructionEditor.cs" />
<Compile Include="..\MandoCode.Desktop\ViewModels\CheckpointCardHtml.cs" Link="src\CheckpointCardHtml.cs" />

<!-- The streamed-response loop and the small WinUI-free collaborators it needs, so it can be
driven by a fake IAiService. TranscriptWriter/BusyStateService are pure event emitters;
ITranscriptHtml keeps the WinUI-coupled TranscriptHtmlBuilder out of the test host. -->
<Compile Include="..\MandoCode.Desktop\Services\IAiService.cs" Link="src\IAiService.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ITranscriptHtml.cs" Link="src\ITranscriptHtml.cs" />
<Compile Include="..\MandoCode.Desktop\Services\PlanCardHtml.cs" Link="src\PlanCardHtml.cs" />
<Compile Include="..\MandoCode.Desktop\Services\TranscriptWriter.cs" Link="src\TranscriptWriter.cs" />
<Compile Include="..\MandoCode.Desktop\Services\BusyStateService.cs" Link="src\BusyStateService.cs" />
<Compile Include="..\MandoCode.Desktop\ViewModels\ResponseStreamer.cs" Link="src\ResponseStreamer.cs" />
Expand Down
31 changes: 31 additions & 0 deletions src/MandoCode.Desktop.Tests/PlanCardTests.cs
Original file line number Diff line number Diff line change
@@ -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 <ApiClient.cs> & run focused tests."
}
]
};

var html = PlanCardHtml.Build(plan);

Assert.Contains("What it will do", html);
Assert.Contains("Edit &lt;ApiClient.cs&gt; &amp; run focused tests.", html);
Assert.DoesNotContain("Edit <ApiClient.cs>", html);
}
}
60 changes: 60 additions & 0 deletions src/MandoCode.Desktop.Tests/PlanInstructionEditorTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading