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
30 changes: 28 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ for every approved plan. Desktop's version follows the engine generation, so it
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.
- **Settings are split into global defaults and per-agent settings.** The rail's Settings page is
now Default Settings: the starting point every new agent is seeded from, and reachable with no
agent open. A gear in an agent's header, between the snapshot and folder icons, opens that
agent's own settings in a docked pane beside its conversation. Both surfaces are the same form
bound to different targets, so the two can never drift apart.
- **An agent keeps its own settings after it is closed and reopened.** Per-agent settings used to
live only in memory and were lost with the process. An agent now becomes independent the first
time its settings are saved, and is restored on those settings whether it comes back from a
relaunch or from the History panel. An agent that has never been configured keeps following the
defaults, so raising a default still reaches every agent you never touched. API keys are never
written to a per-agent file; they stay in the shared configuration and are supplied to each agent
in memory.
- **Settings apply when saved, not as you type.** Every control edits a pending copy, a Save button
reports how many changes are waiting, and closing the page or pane discards anything unsaved.
Values are still checked as they are entered, so a rejected number is refused where it is typed
rather than at save time. Two further actions on an agent's pane move settings between the two
scopes: Apply Global Defaults replaces an agent's settings with the defaults and lets it follow
them again, and Save to Global Defaults makes an agent's settings the starting point for new ones.

### Changed
- **Completed turns now keep routine activity out of the conversation flow.** File operations,
Expand Down Expand Up @@ -160,6 +178,12 @@ for every approved plan. Desktop's version follows the engine generation, so it
a sticky header, or the suggestion list a field opens when it is filled.

### Fixed
- **The preview pane's open and attach buttons now work on web pages.** Both acted only on a
project file, so on a website they did nothing at all and gave no reason why. Open now hands the
page to the system's default browser and the attach button puts its address into the prompt,
while a preview of a project file still opens that file in its default application. A file
preview is served through an address that only resolves inside the app, so the two cases stay
deliberately distinct. Only ordinary web addresses are handed to the system.
- **Per-tab model choices survive a restart.** Restoring a session initialized every tab at once,
and a tab boots on the default model before moving onto its own saved one. Any workspace write
during that window recorded the default over a tab's real model, so an agent you had switched
Expand All @@ -181,10 +205,12 @@ for every approved plan. Desktop's version follows the engine generation, so it
not conversation messages, so stale actions are not replayed into a restored session.

### Test coverage
303 Desktop tests pass. New host-level coverage exercises deferred plan execution, instruction
330 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. Browser coverage adds explicit tab targeting, frame identity, and
plan-card review content. The same workflows were also exercised with real models, including
plan-card review content. Settings coverage pins the rules a saved agent depends on: that a stored
agent configuration never contains an API key, that it is unaffected by app-wide changes made
elsewhere, and that an agent matching the defaults is treated as still following them. The same workflows were also exercised with real models, including
closing the process between steps and resuming from the saved cursor.

An opt-in smoke test drives a real WebView2 browser end to end: DOM reads, pointer and keyboard
Expand Down
136 changes: 136 additions & 0 deletions src/MandoCode.Desktop.Tests/AgentConfigStoreTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using MandoCode.Desktop.Services;
using MandoCode.Models;
using Xunit;

namespace MandoCode.Desktop.Tests;

/// <summary>
/// The per-agent settings snapshot. Two claims here are load-bearing enough to be worth pinning,
/// and neither is visible from the file the user ends up with:
///
/// 1. The API key is NOT in it. Per-agent settings mean one file per configured agent, so a
/// secret that rode along would be a secret in N places instead of one.
/// 2. The fingerprint is STABLE against app-wide churn. An agent is "configured" — and stops
/// inheriting the defaults — precisely when its fingerprint moves, so anything that shifts the
/// fingerprint without the user changing a setting silently orphans every open agent from the
/// defaults. Replacing the shared MCP server dictionary is exactly that kind of churn.
/// </summary>
public sealed class AgentConfigStoreTests
{
[Fact]
public void Fingerprint_OmitsTheApiKey()
{
var config = new MandoCodeConfig { TavilyApiKey = "tvly-super-secret-value" };

var json = AgentConfigStore.Fingerprint(config);

Assert.DoesNotContain("tvly-super-secret-value", json);
Assert.DoesNotContain("tavilyApiKey", json);
}

[Fact]
public void Fingerprint_IgnoresTheApiKeyEntirely()
{
// Setting a key is not "configuring the agent" — it's app-wide, so it must not be what
// pushes an agent off the defaults.
var withoutKey = AgentConfigStore.Fingerprint(new MandoCodeConfig());
var withKey = AgentConfigStore.Fingerprint(new MandoCodeConfig { TavilyApiKey = "tvly-abc" });

Assert.Equal(withoutKey, withKey);
}

[Fact]
public void Fingerprint_IsUnchangedWhenTheSharedMcpServersAreReplaced()
{
var config = new MandoCodeConfig();
config.McpServers["solana"] = new McpServerConfig { Command = "npx" };
config.ValidateAndClamp();
var before = AgentConfigStore.Fingerprint(config);

// What editing the MCP page does to every live agent (ConfigCoordinator.SyncMcpServersToAgents):
// a whole new dictionary, with entries added in a different order.
var replacement = new MandoCodeConfig();
replacement.McpServers["github"] = new McpServerConfig { Command = "npx" };
replacement.McpServers["solana"] = new McpServerConfig { Command = "uvx" };
replacement.ValidateAndClamp();
config.McpServers = replacement.McpServers;

Assert.Equal(before, AgentConfigStore.Fingerprint(config));
}

[Fact]
public void Fingerprint_MovesWhenARealSettingChanges()
{
var config = new MandoCodeConfig();
var before = AgentConfigStore.Fingerprint(config);

config.Temperature = config.Temperature + 0.25;

Assert.NotEqual(before, AgentConfigStore.Fingerprint(config));
}

[Fact]
public void Fingerprint_IgnoresTheAgentsOwnName()
{
// AgentName is [JsonIgnore] on the harness type — it names one tab, not a setting. If it
// ever started serializing, every rename would masquerade as a settings change.
var config = new MandoCodeConfig();
var before = AgentConfigStore.Fingerprint(config);

config.AgentName = "Kernel";

Assert.Equal(before, AgentConfigStore.Fingerprint(config));
}

[Fact]
public void Fingerprint_MatchesTheDefaultsAfterCopyingThemOn()
{
// The equality the "inheriting agent" rule rests on: after CopyOnto, an agent is
// indistinguishable from the defaults, so AgentSession drops its saved file and goes back to
// tracking them. If these two ever stopped agreeing, "Match Global Defaults" would leave the
// agent permanently marked as configured.
var defaults = new MandoCodeConfig { Temperature = 0.35, MaxTokens = 4096 };
var agent = new MandoCodeConfig { Temperature = 0.9, MaxTokens = 512, AgentName = "Kernel" };

ConfigCloning.CopyOnto(defaults, agent);

Assert.Equal(AgentConfigStore.Fingerprint(defaults), AgentConfigStore.Fingerprint(agent));
}

[Fact]
public void Fingerprint_MatchesTheDefaultsForAFreshClone()
{
// Same rule at the other end: a brand-new agent is a clone of the defaults, so it must start
// out fingerprint-identical or every new agent would immediately look "configured".
var defaults = new MandoCodeConfig { Temperature = 0.35 };
defaults.McpServers["Solana"] = new McpServerConfig { Command = "npx" };
defaults.ValidateAndClamp();

var clone = ConfigCloning.DeepClone(defaults);

Assert.Equal(AgentConfigStore.Fingerprint(defaults), AgentConfigStore.Fingerprint(clone));
}

[Fact]
public void Fingerprint_RoundTripsThroughTheConfigReader()
{
// The snapshot has to be loadable by the same reader clones use, or a restored agent comes
// back on the defaults with no sign anything went wrong.
var config = new MandoCodeConfig
{
ModelName = "qwen2.5-coder:14b",
OllamaEndpoint = "http://example:1234",
Temperature = 0.15,
EnableDiffApprovals = false,
};

var restored = ConfigCloning.Deserialize(AgentConfigStore.Fingerprint(config));

Assert.NotNull(restored);
Assert.Equal("qwen2.5-coder:14b", restored!.ModelName);
Assert.Equal("http://example:1234", restored.OllamaEndpoint);
Assert.Equal(0.15, restored.Temperature);
Assert.False(restored.EnableDiffApprovals);
Assert.Null(restored.TavilyApiKey); // stripped on the way out, injected on the way in
}
}
148 changes: 148 additions & 0 deletions src/MandoCode.Desktop.Tests/ConfigCloningTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,154 @@ public void DeepClone_RebuildsMcpServers_CaseInsensitive()
Assert.True(clone.McpServers.ContainsKey("SOLANA"));
}

// ---- CopyOnto: the in-place property copy behind both "Make Default for New Agents"
// (agent -> defaults) and "Match Global Defaults" (defaults -> agent). ----

[Fact]
public void CopyOnto_OverwritesTheTargetsSettings()
{
var source = new MandoCodeConfig { Temperature = 0.4, ModelName = "default-model" };
var target = new MandoCodeConfig { Temperature = 0.9, ModelName = "agent-model" };

ConfigCloning.CopyOnto(source, target);

Assert.Equal(0.4, target.Temperature);
Assert.Equal("default-model", target.ModelName);
}

[Fact]
public void CopyOnto_NeverCarriesTheAgentName()
{
// AgentName is the tab's spoken identity, not a setting. Copying defaults (which have no
// name) onto an agent must not blank that agent out of its own system prompt.
var target = new MandoCodeConfig { AgentName = "Kernel" };

ConfigCloning.CopyOnto(new MandoCodeConfig(), target);

Assert.Equal("Kernel", target.AgentName);
}

[Fact]
public void CopyOnto_DoesNotStampAnAgentsNameOntoTheDefaults()
{
// The other direction: "Make Default for New Agents" must not leave one agent's callsign
// sitting on the config every future agent is seeded from.
var defaults = new MandoCodeConfig();

ConfigCloning.CopyOnto(new MandoCodeConfig { AgentName = "Kernel" }, defaults);

Assert.Null(defaults.AgentName);
}

[Fact]
public void CopyOnto_WritesThroughTheExistingInstance()
{
// An agent's AIService, SkillLoader and McpApprovalGate all captured this object at
// construction. Returning a new one instead of writing through leaves them on stale values.
var target = new MandoCodeConfig { MaxTokens = 1024 };
var collaboratorsReference = target;

ConfigCloning.CopyOnto(new MandoCodeConfig { MaxTokens = 8192 }, target);

Assert.Equal(8192, collaboratorsReference.MaxTokens);
}

[Fact]
public void CopyOnto_DoesNotShareCollectionsBetweenTheTwoConfigs()
{
// Reflection assigns reference types straight across; without the deep clone first, one
// side's later edit would silently be both sides'.
var source = new MandoCodeConfig();
source.IgnoreDirectories.Add("dist");
var target = new MandoCodeConfig();

ConfigCloning.CopyOnto(source, target);
target.IgnoreDirectories.Add("coverage");

Assert.DoesNotContain("coverage", source.IgnoreDirectories);
}

[Fact]
public void CopyOnto_LeavesMcpServersCaseInsensitive()
{
var source = new MandoCodeConfig();
source.McpServers["Solana"] = new McpServerConfig { Command = "npx" };
var target = new MandoCodeConfig();

ConfigCloning.CopyOnto(source, target);

Assert.True(target.McpServers.ContainsKey("solana"));
}

// ---- DifferingKeys: the unsaved-changes count behind the settings form's Save button. ----

[Fact]
public void DifferingKeys_IsEmptyForAFreshClone()
{
// A just-opened form must not claim pending changes — its draft is a clone of the live config.
var live = new MandoCodeConfig { Temperature = 0.4, ModelName = "m" };
live.McpServers["Solana"] = new McpServerConfig { Command = "npx" };
live.ValidateAndClamp();

Assert.Empty(ConfigCloning.DifferingKeys(ConfigCloning.DeepClone(live), live));
}

[Fact]
public void DifferingKeys_NamesEachChangedKeyOnce()
{
var live = new MandoCodeConfig { Temperature = 0.4, MaxTokens = 1024 };
var draft = ConfigCloning.DeepClone(live);
draft.Temperature = 0.9;
draft.MaxTokens = 2048;

var keys = ConfigCloning.DifferingKeys(draft, live);

Assert.Equal(2, keys.Count);
Assert.Contains("temperature", keys);
Assert.Contains("maxTokens", keys);
}

[Fact]
public void DifferingKeys_ComparesCollectionsByContent()
{
// The draft holds its own List instance, so a by-reference comparison would report every
// collection as changed and the Save button would never go quiet.
var live = new MandoCodeConfig();
live.IgnoreDirectories.Add("dist");
var draft = ConfigCloning.DeepClone(live);

Assert.DoesNotContain("ignoreDirectories", ConfigCloning.DifferingKeys(draft, live));

draft.IgnoreDirectories.Add("coverage");
Assert.Contains("ignoreDirectories", ConfigCloning.DifferingKeys(draft, live));
}

[Fact]
public void DifferingKeys_HonoursTheIgnoreList()
{
// mcpServers is app-wide and edited on its own page, so the settings form excludes it —
// otherwise an MCP edit made while the form sat open would show up as the user's pending change.
var live = new MandoCodeConfig();
var draft = ConfigCloning.DeepClone(live);
draft.McpServers["github"] = new McpServerConfig { Command = "npx" };

Assert.Contains("mcpServers", ConfigCloning.DifferingKeys(draft, live));
Assert.Empty(ConfigCloning.DifferingKeys(draft, live, "mcpServers"));
}

[Fact]
public void DifferingKeys_IsEmptyAfterCopyOnto()
{
// Save commits the draft with CopyOnto, then the form re-clones — which must leave it clean.
var live = new MandoCodeConfig { Temperature = 0.4 };
var draft = ConfigCloning.DeepClone(live);
draft.Temperature = 0.9;

ConfigCloning.CopyOnto(draft, live);

Assert.Empty(ConfigCloning.DifferingKeys(draft, live));
}

[Fact]
public void DeepClone_PreservesScalarValues()
{
Expand Down
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 @@ -78,6 +78,10 @@
<!-- Pure logic extracted from session-coupled coordinators so it can be tested directly. -->
<Compile Include="..\MandoCode.Desktop\Services\PaneLayout.cs" Link="src\PaneLayout.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ConfigCloning.cs" Link="src\ConfigCloning.cs" />
<!-- Per-agent settings persistence. The store is the file half; what's pinned here is the
fingerprint — which decides whether an agent has been "configured" and must therefore stop
inheriting the defaults, and which must never carry the API key. -->
<Compile Include="..\MandoCode.Desktop\Services\AgentConfigStore.cs" Link="src\AgentConfigStore.cs" />
<Compile Include="..\MandoCode.Desktop\Services\AgentNaming.cs" Link="src\AgentNaming.cs" />
<Compile Include="..\MandoCode.Desktop\Services\AgentCallsigns.cs" Link="src\AgentCallsigns.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ItemTagStore.cs" Link="src\ItemTagStore.cs" />
Expand Down
Loading
Loading