diff --git a/CHANGELOG.md b/CHANGELOG.md index b5d011f..48445ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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. @@ -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 diff --git a/MandoCode b/MandoCode index 0e54348..6673218 160000 --- a/MandoCode +++ b/MandoCode @@ -1 +1 @@ -Subproject commit 0e54348f4507d95cd5e524926687e5f253ee90cc +Subproject commit 667321828d4380e7b76fb8fb0358b4ae77b77b1a diff --git a/src/MandoCode.Desktop.Tests/DeferredPlanCompletionTests.cs b/src/MandoCode.Desktop.Tests/DeferredPlanCompletionTests.cs index bbe18ac..0fe8194 100644 --- a/src/MandoCode.Desktop.Tests/DeferredPlanCompletionTests.cs +++ b/src/MandoCode.Desktop.Tests/DeferredPlanCompletionTests.cs @@ -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] diff --git a/src/MandoCode.Desktop.Tests/RequestPreambleComposerTests.cs b/src/MandoCode.Desktop.Tests/RequestPreambleComposerTests.cs index 7da0080..7e7b865 100644 --- a/src/MandoCode.Desktop.Tests/RequestPreambleComposerTests.cs +++ b/src/MandoCode.Desktop.Tests/RequestPreambleComposerTests.cs @@ -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); @@ -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); } @@ -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); @@ -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); @@ -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); } } diff --git a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs index 5cbbb3f..fc758c6 100644 --- a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs +++ b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs @@ -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) { @@ -52,6 +53,13 @@ public async IAsyncEnumerable ChatStreamAsync( } } + public IAsyncEnumerable ChatStreamWithHostInstructionAsync( + string userMessage, string hostInstruction, CancellationToken cancellationToken = default) + { + LastHostInstruction = hostInstruction; + return ChatStreamAsync(userMessage, cancellationToken); + } + // Unused by the streaming loop. public event Action? OnFunctionInvoked { add { } remove { } } public event Action? OnFunctionCompleted { add { } remove { } } @@ -65,6 +73,7 @@ public event Action? OnFunctionCompleted { add { } remo 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 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(); @@ -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() { diff --git a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs index 56b3320..8729c34 100644 --- a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs +++ b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs @@ -54,12 +54,15 @@ 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 IAsyncEnumerable ChatStreamWithHostInstructionAsync(string userMessage, string hostInstruction, CancellationToken cancellationToken = default) => + _ai.ChatStreamWithHostInstructionAsync(userMessage, hostInstruction, 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 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(); diff --git a/src/MandoCode.Desktop/Services/IAiService.cs b/src/MandoCode.Desktop/Services/IAiService.cs index 2521e55..62fad73 100644 --- a/src/MandoCode.Desktop/Services/IAiService.cs +++ b/src/MandoCode.Desktop/Services/IAiService.cs @@ -36,6 +36,10 @@ public interface IAiService Task AttachMcpPluginsAsync(CancellationToken cancellationToken = default); Task<(bool IsValid, string? ErrorMessage)> ValidateModelAsync(); IAsyncEnumerable ChatStreamAsync(string userMessage, CancellationToken cancellationToken = default); + IAsyncEnumerable ChatStreamWithHostInstructionAsync( + string userMessage, + string hostInstruction, + CancellationToken cancellationToken = default); Task GeneratePlanAsync( string request, string? revisionContext = null, @@ -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. /// void AppendAssistantNote(string text); + void AppendUserNote(string text); int TryRestoreHistoryJson(string json); Task EnterLearnModeAsync(); Task ClearHistoryAsync(); diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.Plans.cs b/src/MandoCode.Desktop/ViewModels/ChatController.Plans.cs index 5f079e9..04a07d3 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.Plans.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.Plans.cs @@ -103,7 +103,9 @@ private async Task HandlePlanCommandAsync(string action) /// 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) + private Task ForcePlanAsync(string goal) => ForcePlanAsync(goal, goal); + + private async Task ForcePlanAsync(string planningRequest, string originalRequest) { if (!IsConnected || ModelError) { @@ -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)) @@ -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(); } diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index cfb35a1..0d91c43 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -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) @@ -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 { @@ -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; @@ -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, @@ -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)) @@ -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) diff --git a/src/MandoCode.Desktop/ViewModels/DeferredPlanCompletion.cs b/src/MandoCode.Desktop/ViewModels/DeferredPlanCompletion.cs index 95ef368..b0df5de 100644 --- a/src/MandoCode.Desktop/ViewModels/DeferredPlanCompletion.cs +++ b/src/MandoCode.Desktop/ViewModels/DeferredPlanCompletion.cs @@ -25,9 +25,9 @@ public sealed record DeferredPlanCompletionResult(string? Manifest, string? Foll /// 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; @@ -75,7 +75,7 @@ public async Task CompleteAsync( _followUpDepth++; try { - var response = await runFollowUpAsync(RejectionFollowUpPrompt, cancellationToken); + var response = await runFollowUpAsync(RejectionHostInstruction, cancellationToken); return new DeferredPlanCompletionResult(null, response); } finally diff --git a/src/MandoCode.Desktop/ViewModels/RequestPreambleComposer.cs b/src/MandoCode.Desktop/ViewModels/RequestPreambleComposer.cs index 2581911..0ebf761 100644 --- a/src/MandoCode.Desktop/ViewModels/RequestPreambleComposer.cs +++ b/src/MandoCode.Desktop/ViewModels/RequestPreambleComposer.cs @@ -2,11 +2,11 @@ namespace MandoCode.Desktop.ViewModels; /// /// 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 ChatController.SubmitAsync 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. /// public static class RequestPreambleComposer @@ -15,8 +15,7 @@ public static string Compose( string request, IReadOnlyList armedContexts, IReadOnlyList<(string Emoji, string Snippet)> reactions, - IReadOnlyList workspaceNotes, - bool needsPlanning) + IReadOnlyList workspaceNotes) { var result = request; @@ -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; } } diff --git a/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs b/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs index 17b6f30..9059d9c 100644 --- a/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs +++ b/src/MandoCode.Desktop/ViewModels/ResponseStreamer.cs @@ -50,11 +50,14 @@ public ResponseStreamer( /// response text (empty when the model returned nothing, was cancelled, or errored) so the caller /// can remember the last response. Never throws — cancellation and errors surface as transcript /// lines, matching the original in-controller behavior. - public async Task StreamAsync(string input, CancellationToken token) + public async Task StreamAsync(string input, CancellationToken token, string? hostInstruction = null) { try { - var enumerator = _ai.ChatStreamAsync(input, token).GetAsyncEnumerator(token); + var stream = string.IsNullOrWhiteSpace(hostInstruction) + ? _ai.ChatStreamAsync(input, token) + : _ai.ChatStreamWithHostInstructionAsync(input, hostInstruction, token); + var enumerator = stream.GetAsyncEnumerator(token); try { _busy.Start("Thinking...");