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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ it moves 0.14.1 → 0.15.0.
Desktop from source, where the engine project now resolves its .NET 10 build.
- **Engine dependencies moved to current releases**, including Model Context Protocol 2.2.0 and
YamlDotNet 18.1.0.
- **Pinned engine commit: `6eb3fc3`** (engine 0.15.0). The exact engine each Desktop release
- **Desktop moved off Semantic Kernel too.** The notes assistant, the snapshot summarizer, and
the skill author each opened their own connection to Ollama through Semantic Kernel; they now
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.

## [0.14.1] — 2026-07-28
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Two problems, one app:
Ollama: swap models per agent, run one entirely on your own hardware for free, or reach for a
hosted model when you want more ceiling. You're never locked to a single closed API.
- **A coding assistant is something you live in all day, not a popup.** So it gets a real native
app built on C#, WinUI 3, and Semantic Kernel's Ollama connector — multiple agents open at once,
app built on C#, WinUI 3, and Microsoft's Agent Framework over Ollama — multiple agents open at once,
a real shell, a file tree that actually knows about git, a place to jot down a thought without
opening a text editor, and a look you can make your own instead of one fixed dark theme.

Expand Down
6 changes: 3 additions & 3 deletions docs/session-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,6 @@ In rough order of value:
- **CLI `--continue`** — `ExportHistoryJson`/`TryRestoreHistoryJson` live in the harness
precisely so the CLI can grow its own resume without new plumbing.
- **Cross-provider carry verification** — verbatim history with function-call content moving
between Ollama and cloud connectors should map cleanly through Semantic Kernel's generic
content types; it deserves a deliberate test before "Keep memory" is treated as guaranteed
across providers (the graceful fallback already handles failure).
between Ollama and cloud connectors should map cleanly through Microsoft.Extensions.AI's
generic content types; it deserves a deliberate test before "Keep memory" is treated as
guaranteed across providers (the graceful fallback already handles failure).
25 changes: 12 additions & 13 deletions src/MandoCode.Desktop.Tests/HistorySummarizerTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using MandoCode.Desktop.Services;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.Extensions.AI;
using Xunit;

namespace MandoCode.Desktop.Tests;
Expand All @@ -12,22 +11,22 @@ namespace MandoCode.Desktop.Tests;
/// </summary>
public sealed class HistorySummarizerTests
{
private static ChatMessageContent Sys(string t) => new(AuthorRole.System, t);
private static ChatMessageContent Usr(string t) => new(AuthorRole.User, t);
private static ChatMessageContent Asst(string t) => new(AuthorRole.Assistant, t);
private static ChatMessage Sys(string t) => new(ChatRole.System, t);
private static ChatMessage Usr(string t) => new(ChatRole.User, t);
private static ChatMessage Asst(string t) => new(ChatRole.Assistant, t);

[Fact]
public void HasContent_False_WhenOnlySystemPrompt()
=> Assert.False(HistorySummarizer.HasContent(new List<ChatMessageContent> { Sys("you are helpful") }));
=> Assert.False(HistorySummarizer.HasContent(new List<ChatMessage> { Sys("you are helpful") }));

[Fact]
public void HasContent_True_WhenUserSpoke()
=> Assert.True(HistorySummarizer.HasContent(new List<ChatMessageContent> { Sys("sys"), Usr("hello") }));
=> Assert.True(HistorySummarizer.HasContent(new List<ChatMessage> { Sys("sys"), Usr("hello") }));

[Fact]
public void Full_SkipsSystemPrompt_AndKeepsBothTurns()
{
var history = new List<ChatMessageContent> { Sys("SECRET SYSTEM"), Usr("hi there"), Asst("hey back") };
var history = new List<ChatMessage> { Sys("SECRET SYSTEM"), Usr("hi there"), Asst("hey back") };

var text = HistorySummarizer.Full(history);

Expand All @@ -39,16 +38,16 @@ public void Full_SkipsSystemPrompt_AndKeepsBothTurns()
[Fact]
public void Full_ReturnsPlaceholder_WhenNothingToSummarize()
=> Assert.Equal("(no prior activity captured)",
HistorySummarizer.Full(new List<ChatMessageContent> { Sys("sys") }));
HistorySummarizer.Full(new List<ChatMessage> { Sys("sys") }));

[Fact]
public void Full_DescribesFunctionCall_WhenTextIsEmpty()
{
var toolTurn = new ChatMessageContent(AuthorRole.Assistant, content: null)
var toolTurn = new ChatMessage(ChatRole.Assistant, new List<AIContent>
{
Items = { new FunctionCallContent("read_file", arguments: new KernelArguments { ["path"] = "Program.cs" }) }
};
var history = new List<ChatMessageContent> { Sys("sys"), toolTurn };
new FunctionCallContent("call-1", "read_file", new Dictionary<string, object?> { ["path"] = "Program.cs" })
});
var history = new List<ChatMessage> { Sys("sys"), toolTurn };

var text = HistorySummarizer.Full(history);

Expand Down
10 changes: 7 additions & 3 deletions src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<!-- ChatMessage / ChatRole / FunctionCallContent, named directly by the HistorySummarizer
tests and the fake IAiService. Declared rather than taken transitively from the harness,
for the reason spelled out in MandoCode.Desktop.csproj. -->
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.9.0" />
</ItemGroup>

<!-- The MandoCode harness is plain net8.0 (no Windows App SDK), so referencing it does NOT
pull the WinUI dependency this project exists to avoid. It supplies MandoCodeConfig (for the
config-clone tests) and Semantic Kernel's ChatMessageContent (for the HistorySummarizer
tests), and DiffModels transitively — which is why the direct DiffModels compile-include
below was removed. -->
config-clone tests) and DiffModels transitively — which is why the direct DiffModels
compile-include below was removed. (Chat history types now come from the
Microsoft.Extensions.AI.Abstractions reference above, not from the harness.) -->
<ItemGroup>
<ProjectReference Include="..\..\MandoCode\src\MandoCode\MandoCode.csproj" />
</ItemGroup>
Expand Down
4 changes: 2 additions & 2 deletions src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
using MandoCode.Desktop.ViewModels;
using MandoCode.Models;
using MandoCode.Services;
using Microsoft.SemanticKernel;
using Microsoft.Extensions.AI;
using Xunit;

namespace MandoCode.Desktop.Tests;
Expand Down Expand Up @@ -66,7 +66,7 @@ public event Action<FunctionExecutionResult>? OnFunctionCompleted { add { } remo
public int TryRestoreHistoryJson(string json) => throw new NotSupportedException();
public Task EnterLearnModeAsync() => throw new NotSupportedException();
public Task ClearHistoryAsync() => throw new NotSupportedException();
public Task<IReadOnlyList<ChatMessageContent>> GetHistoryAsync() => throw new NotSupportedException();
public Task<IReadOnlyList<ChatMessage>> GetHistoryAsync() => throw new NotSupportedException();
}

private static (ResponseStreamer streamer, List<string> blocks) Make(FakeAiService ai)
Expand Down
2 changes: 1 addition & 1 deletion src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public async Task RestoreConversationMemoryAsync()
{
try
{
// 1) Full fidelity: rehydrate the harness's ChatHistory verbatim.
// 1) Full fidelity: rehydrate the harness's chat history verbatim.
var historyJson = SessionHistoryStore.Load(Session.PersistKey);
if (historyJson != null)
{
Expand Down
11 changes: 11 additions & 0 deletions src/MandoCode.Desktop/MandoCode.Desktop.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<!-- Declared here on purpose, even though the harness already brings both in: the Desktop
services below (NoteAssistant, SnapshotEnhancer, SkillAuthor) call Ollama directly and
name these types themselves. Relying on the harness's transitive copies is what broke
this project when the harness dropped Semantic Kernel — a dependency we compiled against
but never declared. Keep the versions in step with MandoCode/src/MandoCode.csproj.
OllamaSharp's analyzers are excluded for the same reason they are in the harness: its
source generator targets a newer Roslyn than current VS/SDK builds ship (CS9057), and it
only generates code for [OllamaTool] types, which nothing here uses. -->
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.9.0" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.9.0" />
<PackageReference Include="OllamaSharp" Version="5.4.30" ExcludeAssets="analyzers" />
</ItemGroup>

<ItemGroup>
Expand Down
4 changes: 2 additions & 2 deletions src/MandoCode.Desktop/Services/AiServiceAdapter.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using MandoCode.Models;
using MandoCode.Services;
using Microsoft.SemanticKernel;
using Microsoft.Extensions.AI;

namespace MandoCode.Desktop.Services;

Expand Down Expand Up @@ -58,5 +58,5 @@ public Func<string, Task<DiffApprovalResult>>? OnCommandApprovalRequested
public int TryRestoreHistoryJson(string json) => _ai.TryRestoreHistoryJson(json);
public Task EnterLearnModeAsync() => _ai.EnterLearnModeAsync();
public Task ClearHistoryAsync() => _ai.ClearHistoryAsync();
public Task<IReadOnlyList<ChatMessageContent>> GetHistoryAsync() => _ai.GetHistoryAsync();
public Task<IReadOnlyList<ChatMessage>> GetHistoryAsync() => _ai.GetHistoryAsync();
}
50 changes: 37 additions & 13 deletions src/MandoCode.Desktop/Services/HistorySummarizer.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using System.Text;
using Microsoft.SemanticKernel;
using Microsoft.Extensions.AI;

namespace MandoCode.Desktop.Services;

Expand All @@ -11,32 +11,38 @@ namespace MandoCode.Desktop.Services;
/// (The deterministic per-line/overall truncation this once did — a port of the harness's compaction
/// summary — was dropped when snapshots moved to LLM summaries: it kept the oldest turns and cut the
/// most recent, which is backwards for a resumption recap.)
///
/// Messages are <see cref="ChatMessage"/> (Microsoft.Extensions.AI) since the engine moved off
/// Semantic Kernel. Tool activity lives in <see cref="ChatMessage.Contents"/> alongside any text, so
/// walking Contents — not the <c>Text</c> convenience view — is what surfaces a tool turn.
/// </summary>
public static class HistorySummarizer
{
private const string Empty = "(no prior activity captured)";

/// <summary>True if there is anything worth snapshotting beyond the system prompt at index 0.</summary>
public static bool HasContent(IReadOnlyList<ChatMessageContent> history, int startIndex = 1)
public static bool HasContent(IReadOnlyList<ChatMessage> history, int startIndex = 1)
{
var names = MapCallIdsToNames(history, startIndex);
for (int i = Math.Max(0, startIndex); i < history.Count; i++)
if (!string.IsNullOrEmpty(FormatMessage(history[i], int.MaxValue))) return true;
if (!string.IsNullOrEmpty(FormatMessage(history[i], names, int.MaxValue))) return true;
return false;
}

/// <summary>Full untruncated dump — the text handed to the summarizer.</summary>
public static string Full(IReadOnlyList<ChatMessageContent> history, int startIndex = 1)
public static string Full(IReadOnlyList<ChatMessage> history, int startIndex = 1)
=> Build(history, startIndex, lineMax: int.MaxValue, maxChars: int.MaxValue);

private static string Build(IReadOnlyList<ChatMessageContent> history, int startIndex, int lineMax, int maxChars)
private static string Build(IReadOnlyList<ChatMessage> history, int startIndex, int lineMax, int maxChars)
{
var names = MapCallIdsToNames(history, startIndex);
var sb = new StringBuilder();
for (int i = Math.Max(0, startIndex); i < history.Count; i++)
{
var line = FormatMessage(history[i], lineMax);
var line = FormatMessage(history[i], names, lineMax);
if (string.IsNullOrEmpty(line)) continue;

sb.Append('[').Append(history[i].Role.Label).Append("] ").AppendLine(line);
sb.Append('[').Append(history[i].Role.Value).Append("] ").AppendLine(line);
if (sb.Length > maxChars)
{
sb.AppendLine("... (older entries truncated)");
Expand All @@ -46,17 +52,32 @@ private static string Build(IReadOnlyList<ChatMessageContent> history, int start
return sb.Length == 0 ? Empty : sb.ToString().TrimEnd();
}

/// <summary>
/// Call id → function name, built once per walk. Unlike Semantic Kernel's, MEAI's
/// <see cref="FunctionResultContent"/> carries no function name — only the call id it shares
/// with its matching <see cref="FunctionCallContent"/> — so a result line would otherwise read
/// as a bare id instead of "read_file → ...".
/// </summary>
private static Dictionary<string, string> MapCallIdsToNames(IReadOnlyList<ChatMessage> history, int startIndex)
{
var map = new Dictionary<string, string>();
for (int i = Math.Max(0, startIndex); i < history.Count; i++)
foreach (var item in history[i].Contents)
if (item is FunctionCallContent fc) map[fc.CallId] = fc.Name;
return map;
}

/// <summary>One-line recap of a single message; falls back to function calls/results when the
/// text content is empty (a tool turn).</summary>
private static string FormatMessage(ChatMessageContent msg, int lineMax)
private static string FormatMessage(ChatMessage msg, Dictionary<string, string> callIdToName, int lineMax)
{
var content = msg.Content?.Trim();
var content = msg.Text?.Trim();
if (!string.IsNullOrEmpty(content)) return Cap(content, lineMax);

if (msg.Items == null || msg.Items.Count == 0) return "";
if (msg.Contents.Count == 0) return "";

var parts = new List<string>();
foreach (var item in msg.Items)
foreach (var item in msg.Contents)
{
switch (item)
{
Expand All @@ -65,12 +86,15 @@ private static string FormatMessage(ChatMessageContent msg, int lineMax)
var args = fc.Arguments is { Count: > 0 }
? string.Join(", ", fc.Arguments.Select(kv => $"{kv.Key}={Truncate(kv.Value?.ToString(), 40)}"))
: "";
parts.Add($"called {fc.FunctionName}({args})");
parts.Add($"called {fc.Name}({args})");
break;
}
case FunctionResultContent fr:
parts.Add($"{fr.FunctionName} → {Truncate(fr.Result?.ToString(), 80)}");
{
var name = callIdToName.TryGetValue(fr.CallId, out var n) ? n : fr.CallId;
parts.Add($"{name} → {Truncate(fr.Result?.ToString(), 80)}");
break;
}
case TextContent tc when !string.IsNullOrWhiteSpace(tc.Text):
parts.Add(tc.Text.Trim());
break;
Expand Down
4 changes: 2 additions & 2 deletions src/MandoCode.Desktop/Services/IAiService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using MandoCode.Models;
using MandoCode.Services;
using Microsoft.SemanticKernel;
using Microsoft.Extensions.AI;

namespace MandoCode.Desktop.Services;

Expand Down Expand Up @@ -40,5 +40,5 @@ public interface IAiService
int TryRestoreHistoryJson(string json);
Task EnterLearnModeAsync();
Task ClearHistoryAsync();
Task<IReadOnlyList<ChatMessageContent>> GetHistoryAsync();
Task<IReadOnlyList<ChatMessage>> GetHistoryAsync();
}
35 changes: 13 additions & 22 deletions src/MandoCode.Desktop/Services/NoteAssistant.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System.Text;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.Ollama;
using Microsoft.Extensions.AI;
using OllamaSharp;

namespace MandoCode.Desktop.Services;

Expand All @@ -10,7 +9,7 @@ namespace MandoCode.Desktop.Services;
/// that's open, and asking about the pad as a whole.
///
/// <b>It has no tools, and that is the design.</b> Like <see cref="SnapshotEnhancer"/>, this builds a
/// bare Ollama kernel with no plugins, filters, or shared history — so it cannot read or write a
/// bare Ollama chat client with no tools, middleware, or shared history — so it cannot read or write a
/// single file. "No agent ever touches your note" is therefore true by construction rather than by
/// policy: the only route from a reply into a note is the user pressing Insert or Replace. That's also
/// why it isn't an <c>AIService</c> agent — those exist to change your files, which is the opposite of
Expand Down Expand Up @@ -163,9 +162,9 @@ public static int CountBodiesSent(IReadOnlyList<NoteEntry> full)
}

/// <summary>
/// One streamed round on a throwaway kernel. Stateless by construction: a fresh
/// <see cref="ChatHistory"/> per call, built from the system prompt, the recent thread, and this
/// message. Deltas arrive on a background thread — the caller marshals.
/// One streamed round on a throwaway chat client. Stateless by construction: a fresh message
/// list per call, built from the system prompt, the recent thread, and this message. Deltas
/// arrive on a background thread — the caller marshals.
/// </summary>
private static async Task StreamAsync(
string endpoint,
Expand All @@ -176,33 +175,25 @@ private static async Task StreamAsync(
Action<string> onDelta,
CancellationToken ct)
{
var kernel = Kernel.CreateBuilder()
.AddOllamaChatCompletion(modelId: model, endpoint: new Uri(endpoint))
.Build();
using IChatClient chat = new OllamaApiClient(new Uri(endpoint), model);

var chat = kernel.GetRequiredService<IChatCompletionService>();

var history = new ChatHistory();
history.AddSystemMessage(systemPrompt);
var history = new List<ChatMessage> { new(ChatRole.System, systemPrompt) };

foreach (var turn in thread.TakeLast(ThreadTurns))
{
if (turn.FromUser) history.AddUserMessage(turn.Text);
else history.AddAssistantMessage(turn.Text);
}
history.Add(new ChatMessage(turn.FromUser ? ChatRole.User : ChatRole.Assistant, turn.Text));

history.AddUserMessage(message);
history.Add(new ChatMessage(ChatRole.User, message));

// Low temperature: this rewrites the user's own words, so faithful beats inventive.
var settings = new OllamaPromptExecutionSettings { Temperature = 0.3f };
var options = new ChatOptions { Temperature = 0.3f };

// ConfigureAwait(false): per-chunk continuations must not hop through the caller's UI
// dispatcher — a small model can stream hundreds of chunks a second.
await foreach (var chunk in chat.GetStreamingChatMessageContentsAsync(history, settings, kernel, ct)
await foreach (var update in chat.GetStreamingResponseAsync(history, options, ct)
.ConfigureAwait(false))
{
if (ct.IsCancellationRequested) return;
if (!string.IsNullOrEmpty(chunk.Content)) onDelta(chunk.Content);
if (!string.IsNullOrEmpty(update.Text)) onDelta(update.Text);
}
}
}
Loading
Loading