From b470eff8d9eebe0a2f0d8a40413c1f81f9d1df7e Mon Sep 17 00:00:00 2001 From: DevMando Date: Thu, 22 Oct 2026 18:12:20 -0700 Subject: [PATCH] fix(cli): guard startup input and prepare v0.16.1 --- README.md | 2 + docs/CHANGELOG.md | 6 + docs/releases/0.16.1.md | 36 +++++ docs/testing.md | 9 ++ scripts/verify-component-stability.ps1 | 5 +- src/MandoCode/Components/App.Lifecycle.cs | 10 +- src/MandoCode/Components/App.razor | 25 ++-- src/MandoCode/MandoCode.csproj | 4 +- .../MandoCode.Tests/StartupRenderingTests.cs | 126 ++++++++++++++++++ 9 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 docs/releases/0.16.1.md create mode 100644 tests/MandoCode.Tests/StartupRenderingTests.cs diff --git a/README.md b/README.md index b730aca..a2e79ad 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ Named workspaces hold **up to four independent agents each**. Keyboard shortcuts ## A new way to work in the terminal — v0.16.0 +**Patch v0.16.1:** fixes a startup race around prompt activation, keeps prompt state updates on the UI dispatcher, and includes clipboard and component lifecycle improvements. See the [release notes](docs/CHANGELOG.md#0161---2026-10-21). + Built on **RazorConsole 0.6.0**, this release brings multiple workspaces, up to four independent agents per workspace, agent collaboration, portable context snapshots, and saved-agent history into one terminal interface. Explore your files, manage MCP servers and Skills, and keep working while other agents handle their tasks. The conversation scrolls above a persistent multiline prompt, with searchable pickers and collapsible tool output. See the feature demos below or read the [changelog](docs/CHANGELOG.md) for the full release details. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3d34e4e..513b93e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to MandoCode will be documented in this file. ## [Unreleased] +## [0.16.1] - 2026-10-21 + +**Startup and component stability.** The chat prompt now waits for model validation and MCP initialization to finish. Prompt state changes run together on the UI dispatcher, and agents closed during startup cannot start an input loop afterward. RazorConsole remains at 0.6.0. + ### Changed - Improve readability of integration menu navigation, delegation completion, component wiring, and Razor parameter lists; retain intent-focused comments and update outdated engine explanations. @@ -20,6 +24,8 @@ All notable changes to MandoCode will be documented in this file. ### Fixed +- Keep the chat input loop closed until model validation and MCP startup finish, even when connection-status updates trigger another render. Apply prompt state changes on the renderer dispatcher and avoid starting input after an agent closes during the startup delay. + - Make clipboard-reader overrides work across platforms while retaining native terminal paste on Linux when only the Windows clipboard reader is available. - Isolate clipboard-command test output from process stdout and verify scoped terminal-control sinks across concurrent agents. - Stabilize CI layout checks under forced ANSI output, use native paths in checkpoint tests, and restore the process working directory after testing bare `cd` commands. diff --git a/docs/releases/0.16.1.md b/docs/releases/0.16.1.md new file mode 100644 index 0000000..b09eaad --- /dev/null +++ b/docs/releases/0.16.1.md @@ -0,0 +1,36 @@ +# MandoCode CLI 0.16.1 release preparation + +Prepared October 21, 2026. This is a CLI patch release; Desktop versioning and RazorConsole.Core 0.6.0 are unchanged. No tag, GitHub release, or NuGet publication has been created. + +## Release summary + +The chat input loop now waits for model validation and MCP initialization to finish, including when startup status updates trigger another render. Prompt state changes run together on the UI dispatcher. Closing an agent during the startup delay prevents its input loop from starting afterward. + +This release also includes the clipboard fixes, component lifecycle refactoring, and test infrastructure improvements listed in the [changelog](../CHANGELOG.md#0161---2026-10-21). + +The startup race is covered by a regression that fails with the previous gate. The exact intermittent repeated-border screen has not been reproduced automatically; successful interactive retesting is encouraging but does not establish that every rendering failure is resolved. + +## Completed validation + +- Release builds and complete test suites on Windows: 1,247 passing tests on net8.0 and 1,247 on net10.0. +- Startup lifecycle checks: 25 fresh test processes per framework, three tests per process, all passing. +- Broader component checks: three fresh test processes per framework, 234 tests per process, all passing. +- Local `MandoCode.0.16.1.nupkg` created and checked for version 0.16.1 and both runtime payloads. +- Package installed as a .NET tool into an isolated directory using only the local package source. The installed executable's `--doctor` command passed against the available local Ollama server. +- Whitespace validation passed. Local debugging settings are excluded from the release changes. + +Local artifacts are under `artifacts/packages/`, `artifacts/release-validation/`, `artifacts/startup-stability/`, and `artifacts/component-stability/`. These ignored files are not committed. + +## Remaining release checks + +- Run the existing GitHub Actions matrix on Windows and Linux for both frameworks after pushing the release branch. Local Windows results do not establish Linux compatibility. +- Complete the [interactive terminal checklist](../testing.md#interactive-terminal-smoke-checklist), including 10–20 launches each from Visual Studio and Windows Terminal. Type immediately, use Alt+N/Alt+W, resize, and exercise model selection with slow/unavailable Ollama and MCP startup. Record the terminal, framework, model, and outcome. +- Review and merge the release changes, then create the v0.16.1 tag/release and publish the package as a separate release action. + +To retest the isolated installed package from the repository root: + +```powershell +./artifacts/tool-smoke/v0.16.1/mandocode.exe +``` + +Repeatable startup and component commands are documented in [testing.md](../testing.md#stability-and-targeted-mutation-checks). diff --git a/docs/testing.md b/docs/testing.md index 9a65ceb..58e1782 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -55,6 +55,15 @@ Run repeated component checks locally: Each iteration runs in a new test process and preserves a separate TRX result under ignored `artifacts/`. A failure stops the script; hang diagnostics bound stalled runs. Passing repeated runs reduces uncertainty but does not prove the absence of intermittent failures. +For startup release checks, focus repetitions on the startup lifecycle regressions: + +```powershell +./scripts/verify-component-stability.ps1 -Framework net10.0 -Iterations 25 -TestFilter "FullyQualifiedName~StartupRenderingTests" -ResultsDirectory artifacts/startup-stability/net10.0 +./scripts/verify-component-stability.ps1 -Framework net8.0 -Iterations 25 -TestFilter "FullyQualifiedName~StartupRenderingTests" -ResultsDirectory artifacts/startup-stability/net8.0 +``` + +These tests cover renders during incomplete initialization, closing an agent during the startup delay, and retaining one prompt/input owner across refreshes. They do not reproduce every Windows Terminal or Visual Studio launch condition. Before release, also perform 10–20 cold launches from each, type immediately, and create/close agents with Alt+N/Alt+W. Include a slow or unavailable Ollama endpoint and enabled MCP servers, and check narrow/wide windows. Record any duplicated borders, missing focus, or failed submission rather than treating the new-agent workaround as a pass. + The focused mutation probe requires Python and .NET: ```powershell diff --git a/scripts/verify-component-stability.ps1 b/scripts/verify-component-stability.ps1 index 59faa1e..230ef7b 100644 --- a/scripts/verify-component-stability.ps1 +++ b/scripts/verify-component-stability.ps1 @@ -1,7 +1,8 @@ param( [ValidateRange(1, 100)] [int] $Iterations = 5, [ValidateSet("net10.0", "net8.0")] [string] $Framework = "net10.0", - [string] $ResultsDirectory = "" + [string] $ResultsDirectory = "", + [string] $TestFilter = "Category=Component" ) $ErrorActionPreference = "Stop" $repository = Split-Path $PSScriptRoot -Parent @@ -11,7 +12,7 @@ if (!$ResultsDirectory) { $ResultsDirectory = Join-Path $repository "artifacts/c if ($LASTEXITCODE -ne 0) { throw "Test build failed ($LASTEXITCODE)." } for ($iteration = 1; $iteration -le $Iterations; $iteration++) { Write-Host "Component stability: $Framework run $iteration of $Iterations" - & dotnet test $project -c Release -f $Framework --no-build --filter "Category=Component" ` + & dotnet test $project -c Release -f $Framework --no-build --filter $TestFilter ` --results-directory $ResultsDirectory --logger "trx;LogFileName=component-$iteration.trx" ` --blame-hang-timeout 2m --verbosity quiet if ($LASTEXITCODE -ne 0) { throw "Component run $iteration failed ($LASTEXITCODE). See $ResultsDirectory." } diff --git a/src/MandoCode/Components/App.Lifecycle.cs b/src/MandoCode/Components/App.Lifecycle.cs index f9e12e2..5cadfd1 100644 --- a/src/MandoCode/Components/App.Lifecycle.cs +++ b/src/MandoCode/Components/App.Lifecycle.cs @@ -196,6 +196,8 @@ await InvokeAsync(() => conversation = _messages return conversation; } + private bool _startupComplete; + protected override async Task OnAfterRenderAsync(bool firstRender) { using var output = Pane is null ? null : TuiConsole.Enter(Pane.Session); @@ -208,9 +210,12 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender && !_connectionChecked) { await InitializeConnectionAsync(); + _startupComplete = true; } - if (_connectionChecked && !_hasRendered) + // Connection status becomes available before model validation and MCP finish. + // Those steps can render again; only the completed startup may claim input. + if (_startupComplete && _connectionChecked && !_hasRendered && !_disposed && !_paneStopped) { _hasRendered = true; RestoreAgentHistory(); @@ -232,6 +237,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // Component input owns the keyboard; the imperative adapter is compatibility-only. // Small delay to ensure UI is fully rendered before accepting input await Task.Delay(100); + if (_disposed || _paneStopped) return; _ = Task.Run(async () => await RunInteractiveLoopAsync()); } } @@ -288,4 +294,4 @@ public async ValueTask DisposeAsync() Dispose(); await Pane.DisposeAsync(); } -} \ No newline at end of file +} diff --git a/src/MandoCode/Components/App.razor b/src/MandoCode/Components/App.razor index 2625028..cc901cf 100644 --- a/src/MandoCode/Components/App.razor +++ b/src/MandoCode/Components/App.razor @@ -1875,18 +1875,25 @@ // of the interactive loop — blocking Spectre prompts, guarded renders, Spinner.Stop's // task.Wait) would resume inline ON the dispatcher and freeze the UI. Mirrors // InstructionPromptCoordinator, which already uses this for the same reason. - _inputTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _promptInitialValue = ""; - _showPrompt = true; - _isProcessing = false; - await InvokeAsync(StateHasChanged); + var inputCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await InvokeAsync(() => + { + _inputTcs = inputCompletion; + _promptInitialValue = ""; + _showPrompt = true; + _isProcessing = false; + StateHasChanged(); + }); - var input = await _inputTcs.Task; + var input = await inputCompletion.Task; if (_paneStopped) return string.Empty; - _showPrompt = true; - _isProcessing = true; - await InvokeAsync(StateHasChanged); + await InvokeAsync(() => + { + _showPrompt = true; + _isProcessing = true; + StateHasChanged(); + }); return input; } diff --git a/src/MandoCode/MandoCode.csproj b/src/MandoCode/MandoCode.csproj index 87e541b..050948a 100644 --- a/src/MandoCode/MandoCode.csproj +++ b/src/MandoCode/MandoCode.csproj @@ -13,14 +13,14 @@ MandoCode - 0.16.0 + 0.16.1 Armando Fernandez (DevMando) An AI coding assistant built for the developer workflow: independent agents, workspaces, portable context, and local or cloud models through Ollama. https://github.com/DevMando/MandoCode https://github.com/DevMando/MandoCode MIT ai;cli;coding-assistant;ollama;agent-framework;razorconsole;multi-agent;local-llm;dotnet10;dotnet8 - v0.16.0 introduces a RazorConsole 0.6.0 terminal layout, multiple agents and workspaces, agent collaboration, context snapshots, history, MCP and Skills managers, vision attachments, and HTML transcript export. Full release notes: https://github.com/DevMando/MandoCode/blob/v0.16.0/docs/CHANGELOG.md + v0.16.1 fixes a startup race that could activate the chat prompt before model and MCP initialization finished, keeps prompt updates on the UI dispatcher, and prevents closed agents from starting input. Includes clipboard fixes, component lifecycle refactoring, and stronger regression testing. Full release notes: https://github.com/DevMando/MandoCode/blob/v0.16.1/docs/CHANGELOG.md README.md MC.png MC.ico diff --git a/tests/MandoCode.Tests/StartupRenderingTests.cs b/tests/MandoCode.Tests/StartupRenderingTests.cs new file mode 100644 index 0000000..c87ff79 --- /dev/null +++ b/tests/MandoCode.Tests/StartupRenderingTests.cs @@ -0,0 +1,126 @@ +using System.Reflection; +using MandoCode.Components; +using MandoCode.Models; +using MandoCode.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using RazorConsole.Core; +using RazorConsole.Core.Rendering; +using Xunit; + +namespace MandoCode.Tests; + +[Collection("TUI console routing")] +[Trait("Category", "Component")] +public sealed class StartupRenderingTests +{ + [Fact] + public async Task ConnectionStatusRender_DoesNotStartInputBeforeStartupFinishes() + { + await using var services = Services(); + StartupApp app = null!; + await using var renderer = await Mount(services, value => app = value); + await renderer.Dispatcher.InvokeAsync(async () => + { + Set(app, "_connectionChecked", true); + await app.AfterRender(); + Assert.False(Get(app, "_hasRendered")); + Assert.Null(Get(app, "_inputTcs")); + Assert.False(Get(app, "_showPrompt")); + }); + } + + [Fact] + public async Task ClosingDuringStartupDelay_DoesNotLaunchInputOnDisposedAgent() + { + await using var services = Services(); + StartupApp app = null!; + await using var renderer = await Mount(services, value => app = value); + await renderer.Dispatcher.InvokeAsync(async () => + { + Set(app, "_connectionChecked", true); + Set(app, "_startupComplete", true); + var startup = app.AfterRender(); + app.Dispose(); + await startup; + Assert.Null(Get(app, "_inputTcs")); + }); + } + + [Fact] + public async Task StartupAndRefreshes_KeepOnePromptAndInputOwner() + { + await using var services = Services(); + StartupApp app = null!; + await using var renderer = await Mount(services, value => app = value); + await renderer.Dispatcher.InvokeAsync(async () => + { + Set(app, "_connectionChecked", true); + Set(app, "_startupComplete", true); + await app.AfterRender(); + }); + await renderer.WaitUntilAsync(() => Get(app, "_inputTcs") is not null); + var input = Get(app, "_inputTcs"); + for (var i = 0; i < 12; i++) + { + await renderer.Dispatcher.InvokeAsync(app.AfterRender); + var snapshot = await renderer.Dispatcher.InvokeAsync(renderer.Snapshot); + var nodes = WidgetRendererHarness.Flatten(WidgetRendererHarness.Root(snapshot)); + Assert.Single(nodes, node => node.Attributes.TryGetValue("data-focus-key", out var focusKey) + && focusKey?.StartsWith("prompt-", StringComparison.Ordinal) == true); + Assert.Same(input, Get(app, "_inputTcs")); + } + } + + private static ServiceProvider Services() + { + var registrations = new ServiceCollection().AddLogging(); + registrations.AddRazorConsoleServices(); + registrations.AddSingleton(new Viewport()); + registrations.AddSingleton(); + Program.RegisterAgentServices(registrations, new MandoCodeConfig + { + EnableThemeCustomization = false, AllowPersistence = false, UseAgentNames = false + }, Path.GetTempPath()); + return registrations.BuildServiceProvider(); + } + + private static Task Mount(ServiceProvider services, Action ready) + { + services.GetRequiredService().Add(); + RenderFragment body = _ => builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(StartupApp.Ready), ready); + builder.CloseComponent(); + }; + return WidgetRendererHarness.MountAsync(services, new() { ["AgentBody"] = body }); + } + + private static T Get(App app, string name) => (T)Field(name).GetValue(app)!; + private static void Set(App app, string name, object value) => Field(name).SetValue(app, value); + private static FieldInfo Field(string name) => typeof(App).GetField(name, BindingFlags.NonPublic | BindingFlags.Instance)!; + + public sealed class StartupApp : App + { + [Parameter] public Action Ready { get; set; } = default!; + protected override void OnInitialized() { base.OnInitialized(); Ready(this); } + // Drive startup render reentry deterministically without probing a live model server. + protected override Task OnAfterRenderAsync(bool firstRender) => Task.CompletedTask; + public Task AfterRender() => base.OnAfterRenderAsync(false); + } + private sealed class Viewport : ITerminalViewport + { + public int Width => 120; + public int Height => 40; + public event Action? OnResized { add { } remove { } } + } + private sealed class Lifetime : IHostApplicationLifetime + { + public CancellationToken ApplicationStarted => CancellationToken.None; + public CancellationToken ApplicationStopping => CancellationToken.None; + public CancellationToken ApplicationStopped => CancellationToken.None; + public void StopApplication() { } + } +}