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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ the engine generation, so it moves 0.14.1 → 0.15.0.
replacement is shown for review, and execution resumes only after approval.

### Changed
- **Automatic plans now start for the work that actually benefits from them.** Desktop recognizes
explicit checklists, cross-cutting changes, and multiple deliverables instead of treating a long
message as complex. Questions, research, explanations, and narrow edits stay conversational, and
the transcript says why planning started. `/plan <goal>` still forces a plan at any time.
- **User messages remain the user's own words.** Automatic planning is routed directly by the host;
rejected-plan follow-ups and forced skills carry separate, temporary system guidance instead of
appending hidden `[system: ...]` text to a user-role message.
- **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.
Expand Down Expand Up @@ -55,6 +62,9 @@ the engine generation, so it moves 0.14.1 → 0.15.0.
Desktop against a real `@directory` request.

### Fixed
- **The token total now reflects what the provider actually processed.** Desktop no longer adds
rough character-based estimates for reads, searches, web results, writes, or attachments on top
of the provider's prompt and completion counts. File reads still show their line counts.
- **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
Expand Down
2 changes: 1 addition & 1 deletion src/MandoCode.Desktop.Tests/DeferredPlanCompletionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public async Task RejectedPlan_RunsExactlyOneDirectFollowUp()

Assert.Null(result.Manifest);
Assert.Equal("direct answer", result.FollowUpResponse);
Assert.Equal([DeferredPlanCompletion.RejectionFollowUpPrompt], prompts);
Assert.Equal([DeferredPlanCompletion.RejectionHostInstruction], prompts);
}

[Fact]
Expand Down
28 changes: 7 additions & 21 deletions src/MandoCode.Desktop.Tests/RequestPreambleComposerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,13 @@ public sealed class RequestPreambleComposerTests
[Fact]
public void NoRideAlongs_ReturnsRequestUnchanged()
=> Assert.Equal("do the thing",
RequestPreambleComposer.Compose("do the thing", None, NoReactions, None, needsPlanning: false));

[Fact]
public void Planning_AppendsProposePlanNudge()
{
var result = RequestPreambleComposer.Compose("build a feature", None, NoReactions, None, needsPlanning: true);

Assert.StartsWith("build a feature", result);
Assert.Contains("propose_plan", result);
}
RequestPreambleComposer.Compose("do the thing", None, NoReactions, None));

[Fact]
public void ArmedContext_IsFramedAsBackground_AndRequestComesLast()
{
var result = RequestPreambleComposer.Compose(
"current ask", new[] { "earlier recap" }, NoReactions, None, needsPlanning: false);
"current ask", new[] { "earlier recap" }, NoReactions, None);

Assert.Contains("Imported context — 1 recap", result);
Assert.Contains("earlier recap", result);
Expand All @@ -43,7 +34,7 @@ public void ArmedContext_IsFramedAsBackground_AndRequestComesLast()
public void MultipleArmedContexts_Pluralize()
{
var result = RequestPreambleComposer.Compose(
"x", new[] { "a", "b" }, NoReactions, None, needsPlanning: false);
"x", new[] { "a", "b" }, NoReactions, None);

Assert.Contains("2 recaps", result);
}
Expand All @@ -52,7 +43,7 @@ public void MultipleArmedContexts_Pluralize()
public void Reactions_AreFramedAsFeedbackNotText()
{
var result = RequestPreambleComposer.Compose(
"next", None, new[] { ("👍", "the part about caching") }, None, needsPlanning: false);
"next", None, new[] { ("👍", "the part about caching") }, None);

Assert.Contains("reacted to earlier responses", result);
Assert.Contains("👍", result);
Expand All @@ -63,7 +54,7 @@ public void Reactions_AreFramedAsFeedbackNotText()
public void WorkspaceNotes_CarryStalenessWarning()
{
var result = RequestPreambleComposer.Compose(
"keep going", None, NoReactions, new[] { "user ran: git checkout main" }, needsPlanning: false);
"keep going", None, NoReactions, new[] { "user ran: git checkout main" });

Assert.Contains("Workspace changes since your last turn", result);
Assert.Contains("may be stale", result);
Expand All @@ -77,16 +68,11 @@ public void AllRideAlongs_NestWithRequestStillLast()
"the real ask",
new[] { "recap" },
new[] { ("🎉", "snippet") },
new[] { "external edit" },
needsPlanning: true);
new[] { "external edit" });

Assert.Contains("Imported context", result);
Assert.Contains("reacted to earlier responses", result);
Assert.Contains("Workspace changes since your last turn", result);
Assert.Contains("propose_plan", result);
// The real ask survives, followed only by the planning nudge.
var askIndex = result.LastIndexOf("the real ask", System.StringComparison.Ordinal);
Assert.True(askIndex >= 0);
Assert.True(result.IndexOf("propose_plan", System.StringComparison.Ordinal) > askIndex);
Assert.EndsWith("[Current request:]\nthe real ask", result);
}
}
20 changes: 20 additions & 0 deletions src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ private sealed class FakeAiService : IAiService
{
private readonly string[] _segments;
private readonly Exception? _throw;
public string? LastHostInstruction { get; private set; }

public FakeAiService(string[] segments, Exception? throwOnStream = null)
{
Expand All @@ -52,6 +53,13 @@ public async IAsyncEnumerable<string> ChatStreamAsync(
}
}

public IAsyncEnumerable<string> ChatStreamWithHostInstructionAsync(
string userMessage, string hostInstruction, CancellationToken cancellationToken = default)
{
LastHostInstruction = hostInstruction;
return ChatStreamAsync(userMessage, cancellationToken);
}

// Unused by the streaming loop.
public event Action<FunctionCall>? OnFunctionInvoked { add { } remove { } }
public event Action<FunctionExecutionResult>? OnFunctionCompleted { add { } remove { } }
Expand All @@ -65,6 +73,7 @@ public event Action<FunctionExecutionResult>? OnFunctionCompleted { add { } remo
public Task<GeneratedPlan> 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 void AppendUserNote(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();
Expand Down Expand Up @@ -97,6 +106,17 @@ public async Task EachTurn_BecomesItsOwnCard_AndReturnsJoinedText()
Assert.Equal(new[] { "a:hello", "a:world" }, logged);
}

[Fact]
public async Task HostInstruction_UsesSeparateAiServiceChannel()
{
var ai = new FakeAiService(new[] { "done" });
var (s, _) = Make(ai);

await s.StreamAsync("the user text", CancellationToken.None, "host-owned guidance");

Assert.Equal("host-owned guidance", ai.LastHostInstruction);
}

[Fact]
public async Task NoChunks_WarnsNoResponse_AndReturnsEmpty()
{
Expand Down
3 changes: 3 additions & 0 deletions src/MandoCode.Desktop/Services/AiServiceAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,15 @@ public Func<string, Task<DiffApprovalResult>>? OnCommandApprovalRequested
public Task AttachMcpPluginsAsync(CancellationToken cancellationToken = default) => _ai.AttachMcpPluginsAsync(cancellationToken);
public Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync() => _ai.ValidateModelAsync();
public IAsyncEnumerable<string> ChatStreamAsync(string userMessage, CancellationToken cancellationToken = default) => _ai.ChatStreamAsync(userMessage, cancellationToken);
public IAsyncEnumerable<string> ChatStreamWithHostInstructionAsync(string userMessage, string hostInstruction, CancellationToken cancellationToken = default) =>
_ai.ChatStreamWithHostInstructionAsync(userMessage, hostInstruction, cancellationToken);
public Task<GeneratedPlan> 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 void AppendUserNote(string text) => _ai.AppendUserNote(text);
public int TryRestoreHistoryJson(string json) => _ai.TryRestoreHistoryJson(json);
public Task EnterLearnModeAsync() => _ai.EnterLearnModeAsync();
public Task ClearHistoryAsync() => _ai.ClearHistoryAsync();
Expand Down
5 changes: 5 additions & 0 deletions src/MandoCode.Desktop/Services/IAiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ public interface IAiService
Task AttachMcpPluginsAsync(CancellationToken cancellationToken = default);
Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync();
IAsyncEnumerable<string> ChatStreamAsync(string userMessage, CancellationToken cancellationToken = default);
IAsyncEnumerable<string> ChatStreamWithHostInstructionAsync(
string userMessage,
string hostInstruction,
CancellationToken cancellationToken = default);
Task<GeneratedPlan> GeneratePlanAsync(
string request,
string? revisionContext = null,
Expand All @@ -50,6 +54,7 @@ void SetRequestContext(string? request) { }
/// manifest, which must land in history without giving the model an open turn to redo the work in.
/// </summary>
void AppendAssistantNote(string text);
void AppendUserNote(string text);
int TryRestoreHistoryJson(string json);
Task EnterLearnModeAsync();
Task ClearHistoryAsync();
Expand Down
12 changes: 8 additions & 4 deletions src/MandoCode.Desktop/ViewModels/ChatController.Plans.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ private async Task HandlePlanCommandAsync(string action)
/// from the normal agent and can only call <c>propose_plan</c>; the resulting plan then enters
/// the exact same review/edit/approval flow as a heuristic proposal.
/// </summary>
private async Task ForcePlanAsync(string goal)
private Task ForcePlanAsync(string goal) => ForcePlanAsync(goal, goal);

private async Task ForcePlanAsync(string planningRequest, string originalRequest)
{
if (!IsConnected || ModelError)
{
Expand All @@ -118,10 +120,11 @@ private async Task ForcePlanAsync(string goal)

try
{
_ai.AppendUserNote(originalRequest);
_deferredPlans.Outcome = DeferredPlanOutcome.None;
var proposal = await _ai.GeneratePlanAsync(goal, cancellationToken: token);
var proposal = await _ai.GeneratePlanAsync(planningRequest, cancellationToken: token);
var result = await _planHandoff.ProcessAsync(
proposal.Goal, proposal.Steps, token, originalRequest: goal);
proposal.Goal, proposal.Steps, token, originalRequest: originalRequest);

if (_deferredPlans.Outcome == DeferredPlanOutcome.Executed &&
!string.IsNullOrWhiteSpace(result))
Expand All @@ -134,7 +137,8 @@ private async Task ForcePlanAsync(string goal)
// "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);
"Proceed with my original request directly.", token,
DeferredPlanCompletion.RejectionHostInstruction);
if (!string.IsNullOrEmpty(response)) _lastAiResponse = response;
_planHandoff.ClearPendingProposal();
}
Expand Down
39 changes: 26 additions & 13 deletions src/MandoCode.Desktop/ViewModels/ChatController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -433,18 +433,17 @@ public async Task SubmitAsync(string input)
ConversationLogger?.Invoke("u", input);

// Plan heuristic BEFORE @file expansion so attachments don't inflate it.
var needsPlanning = _taskPlanner.RequiresPlanning(input);
var planning = _taskPlanner.GetPlanningDecision(input);
var processedInput = ProcessFileReferences(input);

// Fold the invisible ride-alongs (imported recaps, emoji reactions, external workspace
// changes) and any planning nudge into the message the model sees. See
// changes) into the request context. See
// RequestPreambleComposer for the exact framing.
processedInput = RequestPreambleComposer.Compose(
processedInput,
_armedContexts,
_pendingReactions.Select(r => (r.Emoji, r.Snippet)).ToList(),
_pendingWorkspaceNotes,
needsPlanning);
_pendingWorkspaceNotes);

// Ride-alongs are one-shot — clear what we just folded in so it isn't sent twice.
if (_armedContexts.Count > 0)
Expand All @@ -455,7 +454,13 @@ public async Task SubmitAsync(string input)
_pendingReactions.Clear();
_pendingWorkspaceNotes.Clear();

await ProcessDirectRequestAsync(processedInput, input);
if (planning.Required)
{
_transcript.Append(_html.Dim($"Planning automatically: {planning.Reason}."));
await ForcePlanAsync(processedInput, input);
}
else
await ProcessDirectRequestAsync(processedInput, input);
}
finally
{
Expand All @@ -475,7 +480,10 @@ public void CancelActiveRequest()
// Direct AI request (port of ProcessDirectRequestAsync)
// ============================================================

private async Task ProcessDirectRequestAsync(string input, string? originalRequest = null)
private async Task ProcessDirectRequestAsync(
string input,
string? originalRequest = null,
string? hostInstruction = null)
{
// Reset the per-request operation tracking the function-call event handlers read.
_recentReadCount = 0;
Expand All @@ -492,7 +500,7 @@ private async Task ProcessDirectRequestAsync(string input, string? originalReque

try
{
var response = await _streamer.StreamAsync(input, token);
var response = await _streamer.StreamAsync(input, token, hostInstruction);
if (!string.IsNullOrEmpty(response)) _lastAiResponse = response;

// AIService sees the expanded/preambled model input. Before the queued plan is taken,
Expand All @@ -509,7 +517,10 @@ private async Task ProcessDirectRequestAsync(string input, string? originalReque
}
else
{
var completion = await _deferredPlans.CompleteAsync(token, _streamer.StreamAsync);
var completion = await _deferredPlans.CompleteAsync(
token,
(hostInstruction, ct) => _streamer.StreamAsync(
"Continue with my original request.", ct, hostInstruction));
if (!string.IsNullOrEmpty(completion.FollowUpResponse))
_lastAiResponse = completion.FollowUpResponse;
if (!string.IsNullOrWhiteSpace(completion.Manifest))
Expand Down Expand Up @@ -1831,12 +1842,14 @@ private async Task HandleForceSkillCommandAsync(string skillName)
if (!string.IsNullOrWhiteSpace(skill.Description))
_transcript.Append(_html.Dim(skill.Description));

var forcedPrompt =
$"[system: the user has explicitly forced skill '{skill.Name}' via /force-skill. " +
$"Follow these instructions exactly for this turn, even if they differ from your defaults.]\n\n" +
$"# Skill: {skill.Name}\n\n{skill.Body}";
var hostInstruction =
$"The user explicitly selected skill '{skill.Name}' via /force-skill. " +
$"Follow these instructions for this turn.\n\n# Skill: {skill.Name}\n\n{skill.Body}";

await ProcessDirectRequestAsync(forcedPrompt);
await ProcessDirectRequestAsync(
$"/force-skill {skill.Name}",
originalRequest: $"/force-skill {skill.Name}",
hostInstruction: hostInstruction);
}

private async Task HandleMcpCommandAsync(string rawArgs)
Expand Down
8 changes: 4 additions & 4 deletions src/MandoCode.Desktop/ViewModels/DeferredPlanCompletion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ public sealed record DeferredPlanCompletionResult(string? Manifest, string? Foll
/// </summary>
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.]";
public const string RejectionHostInstruction =
"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;
Expand Down Expand Up @@ -75,7 +75,7 @@ public async Task<DeferredPlanCompletionResult> CompleteAsync(
_followUpDepth++;
try
{
var response = await runFollowUpAsync(RejectionFollowUpPrompt, cancellationToken);
var response = await runFollowUpAsync(RejectionHostInstruction, cancellationToken);
return new DeferredPlanCompletionResult(null, response);
}
finally
Expand Down
13 changes: 3 additions & 10 deletions src/MandoCode.Desktop/ViewModels/RequestPreambleComposer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ namespace MandoCode.Desktop.ViewModels;

/// <summary>
/// Assembles the invisible preamble that rides along with a user's message — imported snapshot
/// recaps, emoji reactions, workspace changes made outside the conversation, and a planning nudge.
/// recaps, emoji reactions, and workspace changes made outside the conversation.
/// Extracted from <c>ChatController.SubmitAsync</c> as a pure function so the exact framing (which
/// the model sees but the user never does) can be unit-tested. Each block that fires wraps the
/// running text with its own "[Current request:]" boundary, in the order armed → reactions →
/// workspace; the planning nudge is appended last. All three collections are framed as background
/// workspace. All three collections are framed as background
/// facts, never as text the user typed.
/// </summary>
public static class RequestPreambleComposer
Expand All @@ -15,8 +15,7 @@ public static string Compose(
string request,
IReadOnlyList<string> armedContexts,
IReadOnlyList<(string Emoji, string Snippet)> reactions,
IReadOnlyList<string> workspaceNotes,
bool needsPlanning)
IReadOnlyList<string> workspaceNotes)
{
var result = request;

Expand Down Expand Up @@ -56,12 +55,6 @@ public static string Compose(
"\n\n[Current request:]\n" + result;
}

if (needsPlanning)
{
result += "\n\n[system: this request looks multi-step. " +
"Call propose_plan now with the breakdown before doing any work.]";
}

return result;
}
}
Loading
Loading