From ffbf1e399ef88000b60985af47c4bbce1e89c3f2 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 9 Aug 2026 00:55:50 +0000 Subject: [PATCH 1/4] test(provider): reproduce stale OAuth screen --- .../Tui/ProviderManagerPageTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/Netclaw.Cli.Tests/Tui/ProviderManagerPageTests.cs b/src/Netclaw.Cli.Tests/Tui/ProviderManagerPageTests.cs index 7ea2a8b42..18ed41981 100644 --- a/src/Netclaw.Cli.Tests/Tui/ProviderManagerPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ProviderManagerPageTests.cs @@ -9,6 +9,7 @@ using Netclaw.Cli.Tui; using Netclaw.Configuration; using Netclaw.Providers; +using Netclaw.Providers.OAuth; using Netclaw.Tests.Utilities; using Termina; using Termina.Hosting; @@ -102,6 +103,47 @@ public async Task GitHubCopilotEnterpriseInputs_AcceptTypedHostAndApiBase() Assert.Equal("https://api.ghe.example.com", vm.NewVendorOptions["GitHubApiBase"]); } + [Fact] + public async Task OAuthDeviceFlow_WhenAuthorizationStarts_ShowsTheUserCode() + { + var (terminal, app, vm) = CreateHeadlessApp(out var input); + + using var appCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var run = app.RunAsync(appCts.Token); + + try + { + await WaitForConditionAsync(() => terminal.Contains("Provider Manager"), appCts.Token); + + vm.NewProviderType = "openai"; + vm.CurrentState.Value = ProviderManagerState.AddOAuthDeviceFlow; + vm.StateVersion.Value++; + vm.RequestRedraw(); + + await WaitForConditionAsync( + () => terminal.Contains("Starting device authorization..."), + appCts.Token); + + vm.OAuth.UserCode = "ABCD-EFGH"; + vm.OAuth.VerificationUri = "https://auth.openai.com/device"; + vm.OAuth.FlowState.Value = DeviceFlowState.WaitingForUser; + vm.StateVersion.Value++; + vm.RequestRedraw(); + + using var transitionCts = CancellationTokenSource.CreateLinkedTokenSource(appCts.Token); + transitionCts.CancelAfter(TimeSpan.FromSeconds(2)); + await WaitForConditionAsync( + () => terminal.Contains("Enter code: ABCD-EFGH") + && !terminal.Contains("Starting device authorization..."), + transitionCts.Token); + } + finally + { + input.EnqueueKey(ConsoleKey.Q, control: true); + await run.WaitAsync(appCts.Token); + } + } + private (VirtualTerminal Terminal, TerminaApplication App, ProviderManagerViewModel Vm) CreateHeadlessApp(out VirtualInputSource input) { @@ -178,4 +220,13 @@ private void WriteConfig(Dictionary data) File.WriteAllText(_paths.NetclawConfigPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); } + + private static async Task WaitForConditionAsync(Func predicate, CancellationToken ct) + { + while (!predicate()) + { + ct.ThrowIfCancellationRequested(); + await Task.Yield(); + } + } } From ffbc4d4619ed0c4b063a5351688deb61964c9e73 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 9 Aug 2026 01:21:35 +0000 Subject: [PATCH 2/4] fix(provider): refresh OAuth device flow states --- src/Netclaw.Cli/Tui/ProviderManagerPage.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Netclaw.Cli/Tui/ProviderManagerPage.cs b/src/Netclaw.Cli/Tui/ProviderManagerPage.cs index 09c1ee465..03041b5be 100644 --- a/src/Netclaw.Cli/Tui/ProviderManagerPage.cs +++ b/src/Netclaw.Cli/Tui/ProviderManagerPage.cs @@ -121,6 +121,7 @@ private LayoutNode BuildContent() var state = ViewModel.CurrentState.Value; var revision = state is ProviderManagerState.Loading or ProviderManagerState.AddValidating + or ProviderManagerState.AddOAuthDeviceFlow or ProviderManagerState.Details ? ViewModel.StateVersion.Value : 0; From 41b5a5ee305abb728e02597159fa4f2336d3529d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 9 Aug 2026 02:02:46 +0000 Subject: [PATCH 3/4] fix(model): show configured roles on initial load --- .../Tui/ModelManagerPageTests.cs | 137 ++++++++++++++++++ .../Tui/ModelManagerViewModelTests.cs | 4 +- src/Netclaw.Cli/Tui/ModelManagerPage.cs | 9 +- src/Netclaw.Cli/Tui/ModelManagerViewModel.cs | 7 +- tests/smoke/assertions/model-manager.sh | 7 +- tests/smoke/tapes/model-manager.tape | 24 ++- 6 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 src/Netclaw.Cli.Tests/Tui/ModelManagerPageTests.cs diff --git a/src/Netclaw.Cli.Tests/Tui/ModelManagerPageTests.cs b/src/Netclaw.Cli.Tests/Tui/ModelManagerPageTests.cs new file mode 100644 index 000000000..173a14da5 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/ModelManagerPageTests.cs @@ -0,0 +1,137 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Provider; +using Netclaw.Cli.Tui; +using Netclaw.Configuration; +using Netclaw.Providers; +using Netclaw.Tests.Utilities; +using Termina; +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui; + +public sealed class ModelManagerPageTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + private readonly FakeProviderProbe _fakeProbe = new(); + private readonly ProviderDescriptorRegistry _registry = ProviderCommand.CreateDefaultRegistry(); + + public ModelManagerPageTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public async Task InitialRoleOverview_ShowsConfiguredMainAndFallbackModels() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["deepseek-test"] = new Dictionary + { + ["Type"] = "openai-compatible", + ["Endpoint"] = "https://api.deepseek.example" + }, + ["big-gpu"] = new Dictionary + { + ["Type"] = "openai-compatible", + ["Endpoint"] = "http://gpu.example" + } + }, + ["Models"] = new Dictionary + { + ["Definitions"] = new Dictionary + { + ["deepseek-v4-flash"] = new Dictionary + { + ["Provider"] = "deepseek-test", + ["ModelId"] = "deepseek-v4-flash" + }, + ["fallback-model"] = new Dictionary + { + ["Provider"] = "big-gpu", + ["ModelId"] = "fallback-model" + } + }, + ["Roles"] = new Dictionary + { + ["Main"] = "deepseek-v4-flash", + ["Fallback"] = "fallback-model" + } + } + }); + + var (terminal, app) = CreateHeadlessApp(out var input); + + using var appCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var run = app.RunAsync(appCts.Token); + + try + { + using var overviewCts = CancellationTokenSource.CreateLinkedTokenSource(appCts.Token); + overviewCts.CancelAfter(TimeSpan.FromSeconds(2)); + await WaitForConditionAsync( + () => terminal.Contains("deepseek-test") + && terminal.Contains("deepseek-v4-flash") + && terminal.Contains("big-gpu") + && terminal.Contains("fallback-model"), + overviewCts.Token); + } + finally + { + input.EnqueueKey(ConsoleKey.Q, control: true); + await run.WaitAsync(appCts.Token); + } + } + + private (VirtualTerminal Terminal, TerminaApplication App) CreateHeadlessApp( + out VirtualInputSource input) + { + var terminal = new VirtualTerminal(160, 40); + var virtualInput = new VirtualInputSource(); + input = virtualInput; + + var services = new ServiceCollection(); + services.AddSingleton(terminal); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/model", builder => + { + builder.RegisterRoute( + "/model", + _ => new ModelManagerPage(), + _ => new ModelManagerViewModel(_paths, _fakeProbe, _registry)); + }); + + var serviceProvider = services.BuildServiceProvider(); + return (terminal, serviceProvider.GetRequiredService()); + } + + private void WriteConfig(Dictionary data) + { + File.WriteAllText(_paths.NetclawConfigPath, + JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); + } + + private static async Task WaitForConditionAsync(Func predicate, CancellationToken ct) + { + while (!predicate()) + { + ct.ThrowIfCancellationRequested(); + await Task.Yield(); + } + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs index df9af263a..0cb26a14e 100644 --- a/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs @@ -27,10 +27,10 @@ public ModelManagerViewModelTests() public void Dispose() => _dir.Dispose(); [Fact] - public void StartsAtRoleOverview() + public void StartsInLoadingState() { using var vm = CreateViewModel(); - Assert.Equal(ModelManagerState.RoleOverview, vm.CurrentState.Value); + Assert.Equal(ModelManagerState.Loading, vm.CurrentState.Value); } [Fact] diff --git a/src/Netclaw.Cli/Tui/ModelManagerPage.cs b/src/Netclaw.Cli/Tui/ModelManagerPage.cs index e67b8e0c2..c3068c46f 100644 --- a/src/Netclaw.Cli/Tui/ModelManagerPage.cs +++ b/src/Netclaw.Cli/Tui/ModelManagerPage.cs @@ -67,6 +67,7 @@ private LayoutNode BuildContent() return ViewModel.CurrentState.Value switch { + ModelManagerState.Loading => BuildLoading(), ModelManagerState.RoleOverview => BuildRoleOverview(), ModelManagerState.SelectProvider => BuildProviderSelection(), ModelManagerState.DiscoverModels => BuildDiscoverModels(), @@ -135,9 +136,14 @@ private LayoutNode BuildKeyBindings() // Content views // ═══════════════════════════════════════════════════════════════════ + private static ILayoutNode BuildLoading() + { + return new TextNode(" Loading model configuration...") + .WithForeground(Color.BrightBlack); + } + private ILayoutNode BuildRoleOverview() { - var models = ViewModel.Models; var items = new[] { "Main", "Fallback", "Compaction" }; _roleList = Layouts.SelectionList(items, role => FormatRoleItem(role, role switch @@ -430,6 +436,7 @@ private enum ModelManagerContent RoleOverview = ModelManagerState.RoleOverview, SelectProvider = ModelManagerState.SelectProvider, ConfirmAssignment = ModelManagerState.ConfirmAssignment, + Loading = ModelManagerState.Loading, DiscoverProbing, DiscoverFailed, DiscoverEmpty, diff --git a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs index 770db0395..acb0d40d5 100644 --- a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs @@ -22,7 +22,8 @@ public enum ModelManagerState RoleOverview, SelectProvider, DiscoverModels, - ConfirmAssignment + ConfirmAssignment, + Loading } /// @@ -47,7 +48,7 @@ public sealed class ModelManagerViewModel : ReactiveViewModel /// internal bool IsEmbeddedInConfig { get; set; } - public ReactiveProperty CurrentState { get; } = new(ModelManagerState.RoleOverview); + public ReactiveProperty CurrentState { get; } = new(ModelManagerState.Loading); public ReactiveProperty StatusMessage { get; } = new(""); public ReactiveProperty IsProbing { get; } = new(false); public ReactiveProperty ProbeResult { get; } = new(null); @@ -87,6 +88,8 @@ public override void OnActivated() { base.OnActivated(); Refresh(); + CurrentState.Value = ModelManagerState.RoleOverview; + NotifyStateChanged(); Input.OfType() .Subscribe(HandleGlobalKey) diff --git a/tests/smoke/assertions/model-manager.sh b/tests/smoke/assertions/model-manager.sh index a736599d9..b206582af 100755 --- a/tests/smoke/assertions/model-manager.sh +++ b/tests/smoke/assertions/model-manager.sh @@ -1,9 +1,8 @@ #!/usr/bin/env bash # model-manager.tape post-tape assertion. # -# The tape's Wait+Screen anchors on "Model Manager" and TAPE$ are -# the primary regression detectors — a rendering failure or crash exits -# vhs non-zero. This script intentionally does nothing further. +# The tape anchors on both configured model IDs. These anchors prove that +# the role overview reads configuration after view-model activation. set -euo pipefail -echo "model-manager: no post-tape assertion (vhs exit code is the test)" +echo "model-manager: configured main and fallback roles rendered" diff --git a/tests/smoke/tapes/model-manager.tape b/tests/smoke/tapes/model-manager.tape index acfe425b2..553c5985b 100644 --- a/tests/smoke/tapes/model-manager.tape +++ b/tests/smoke/tapes/model-manager.tape @@ -1,19 +1,31 @@ # model-manager.tape — smoke the `netclaw model` TUI. # -# Validates that the page opens (panel title renders), shows the -# model role assignment view, and exits cleanly on Ctrl+Q. This covers -# ModelManagerPage rendering regressions (including the .WithFillHeight() -# scroll fix from #1351) without requiring a live daemon. +# Validates that the page shows configured main and fallback roles, then +# exits cleanly on Ctrl+Q. This covers the initial keyed-layout refresh +# and the .WithFillHeight() scroll fix from #1351 without a live daemon. Output "/tmp/tape-model-manager.gif" +# ─── Seed configured roles ─────────────────────────────────────────── +Type "netclaw provider add smoke-models ollama --endpoint http://localhost:11434" +Enter +Wait+Screen@10s /Added provider 'smoke-models'/ + +Type "netclaw model set main smoke-models deepseek-v4-flash" +Enter +Wait+Screen@10s /Set main model to smoke-models\/deepseek-v4-flash/ + +Type "netclaw model set fallback smoke-models fallback-model" +Enter +Wait+Screen@10s /Set fallback model to smoke-models\/fallback-model/ + # ─── Launch ────────────────────────────────────────────────────────── Type "netclaw model" Enter -# PanelNode title always renders regardless of daemon state. Wait+Screen@10s /Model Manager/ -Sleep 300ms +Wait+Screen@10s /deepseek-v4-flash/ +Wait+Screen@10s /fallback-model/ # ─── Exit TUI ──────────────────────────────────────────────────────── Ctrl+Q From 21264f8b6ac3f7509b42323c7879394dc103e04e Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 9 Aug 2026 02:41:44 +0000 Subject: [PATCH 4/4] fix(model): replace stale provider model lists --- .../Tui/ModelManagerPageTests.cs | 58 +++++++++++++++++++ src/Netclaw.Cli/Tui/ModelManagerViewModel.cs | 17 +++--- tests/smoke/assertions/model-manager.sh | 6 +- tests/smoke/tapes/model-manager.tape | 34 ++++++++--- 4 files changed, 93 insertions(+), 22 deletions(-) diff --git a/src/Netclaw.Cli.Tests/Tui/ModelManagerPageTests.cs b/src/Netclaw.Cli.Tests/Tui/ModelManagerPageTests.cs index 173a14da5..99e6b5917 100644 --- a/src/Netclaw.Cli.Tests/Tui/ModelManagerPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ModelManagerPageTests.cs @@ -98,6 +98,61 @@ await WaitForConditionAsync( } } + [Fact] + public async Task ModelAssignment_WhenTheUserChangesProvider_ShowsTheSelectedProvidersModels() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["alpha-openai"] = new Dictionary { ["Type"] = "openai" }, + ["bravo-copilot"] = new Dictionary { ["Type"] = "github-copilot" }, + ["charlie-deepseek"] = new Dictionary { ["Type"] = "deepseek" } + } + }); + _fakeProbe.TypeResults["openai"] = SuccessfulProbe("openai-model"); + _fakeProbe.TypeResults["github-copilot"] = SuccessfulProbe("copilot-model"); + _fakeProbe.TypeResults["deepseek"] = SuccessfulProbe("deepseek-model"); + + var (terminal, app) = CreateHeadlessApp(out var input); + + using var appCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var run = app.RunAsync(appCts.Token); + + try + { + await WaitForConditionAsync(() => terminal.Contains("Main"), appCts.Token); + + input.EnqueueKey(ConsoleKey.Enter); + await WaitForConditionAsync(() => terminal.Contains("alpha-openai"), appCts.Token); + input.EnqueueKey(ConsoleKey.Enter); + await WaitForConditionAsync(() => terminal.Contains("openai-model"), appCts.Token); + + input.EnqueueKey(ConsoleKey.Escape); + await WaitForConditionAsync(() => terminal.Contains("Select provider for Main"), appCts.Token); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + await WaitForConditionAsync( + () => terminal.Contains("bravo-copilot") && terminal.Contains("copilot-model"), + appCts.Token); + + input.EnqueueKey(ConsoleKey.Escape); + await WaitForConditionAsync(() => terminal.Contains("Select provider for Main"), appCts.Token); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + await WaitForConditionAsync( + () => terminal.Contains("charlie-deepseek") && terminal.Contains("deepseek-model"), + appCts.Token); + } + finally + { + input.EnqueueKey(ConsoleKey.Q, control: true); + await run.WaitAsync(appCts.Token); + } + } + private (VirtualTerminal Terminal, TerminaApplication App) CreateHeadlessApp( out VirtualInputSource input) { @@ -126,6 +181,9 @@ private void WriteConfig(Dictionary data) JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); } + private static ProviderProbeResult SuccessfulProbe(string modelId) + => new(true, null, [new DiscoveredModel { ModelId = new ModelId(modelId) }]); + private static async Task WaitForConditionAsync(Func predicate, CancellationToken ct) { while (!predicate()) diff --git a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs index acb0d40d5..416ddfd62 100644 --- a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs @@ -154,7 +154,6 @@ public void SelectProvider(string providerName) { SelectedProvider = providerName; CurrentState.Value = ModelManagerState.DiscoverModels; - NotifyStateChanged(); StartProbe(); } @@ -255,7 +254,6 @@ public void StartDiscovery(string providerName) SelectedProvider = providerName; SelectedRole = null; CurrentState.Value = ModelManagerState.DiscoverModels; - NotifyStateChanged(); StartProbe(); } @@ -309,6 +307,13 @@ public void RequestQuit() internal void StartProbe() { CancelProbe(); + ManualModelEntry = false; + SelectedModelId = null; + IsProbing.Value = true; + ProbeResult.Value = null; + ProbeElapsedSeconds.Value = 0; + DiscoveredModels.Clear(); + NotifyStateChanged(); ProbeCompletion = ProbeProviderAsync(); } @@ -340,14 +345,6 @@ internal async Task ProbeProviderAsync() var stopwatch = Stopwatch.StartNew(); Exception? probeException = null; - ManualModelEntry = false; - SelectedModelId = null; - IsProbing.Value = true; - ProbeResult.Value = null; - ProbeElapsedSeconds.Value = 0; - DiscoveredModels.Clear(); - RequestRedraw(); - ProbeDiagnosticsLog.Write( _paths, "model-manager", diff --git a/tests/smoke/assertions/model-manager.sh b/tests/smoke/assertions/model-manager.sh index b206582af..0b4036227 100755 --- a/tests/smoke/assertions/model-manager.sh +++ b/tests/smoke/assertions/model-manager.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # model-manager.tape post-tape assertion. # -# The tape anchors on both configured model IDs. These anchors prove that -# the role overview reads configuration after view-model activation. +# The tape anchors on the configured roles and both selected providers. +# These anchors prove that each provider selection replaces the prior list. set -euo pipefail -echo "model-manager: configured main and fallback roles rendered" +echo "model-manager: configured roles and provider changes rendered" diff --git a/tests/smoke/tapes/model-manager.tape b/tests/smoke/tapes/model-manager.tape index 553c5985b..be81906b2 100644 --- a/tests/smoke/tapes/model-manager.tape +++ b/tests/smoke/tapes/model-manager.tape @@ -1,23 +1,27 @@ # model-manager.tape — smoke the `netclaw model` TUI. # -# Validates that the page shows configured main and fallback roles, then -# exits cleanly on Ctrl+Q. This covers the initial keyed-layout refresh -# and the .WithFillHeight() scroll fix from #1351 without a live daemon. +# Validates configured roles and provider changes in the model picker. +# This covers the keyed-layout refresh and the .WithFillHeight() scroll fix +# from #1351 without a live daemon. Output "/tmp/tape-model-manager.gif" # ─── Seed configured roles ─────────────────────────────────────────── -Type "netclaw provider add smoke-models ollama --endpoint http://localhost:11434" +Type "netclaw provider add alpha-models ollama --endpoint http://localhost:11434" Enter -Wait+Screen@10s /Added provider 'smoke-models'/ +Wait+Screen@10s /Added provider 'alpha-models'/ -Type "netclaw model set main smoke-models deepseek-v4-flash" +Type "netclaw provider add bravo-models ollama --endpoint http://localhost:11434" Enter -Wait+Screen@10s /Set main model to smoke-models\/deepseek-v4-flash/ +Wait+Screen@10s /Added provider 'bravo-models'/ -Type "netclaw model set fallback smoke-models fallback-model" +Type "netclaw model set main alpha-models deepseek-v4-flash" Enter -Wait+Screen@10s /Set fallback model to smoke-models\/fallback-model/ +Wait+Screen@10s /Set main model to alpha-models\/deepseek-v4-flash/ + +Type "netclaw model set fallback alpha-models fallback-model" +Enter +Wait+Screen@10s /Set fallback model to alpha-models\/fallback-model/ # ─── Launch ────────────────────────────────────────────────────────── Type "netclaw model" @@ -27,6 +31,18 @@ Wait+Screen@10s /Model Manager/ Wait+Screen@10s /deepseek-v4-flash/ Wait+Screen@10s /fallback-model/ +# ─── Change the selected provider ─────────────────────────────────── +Enter +Wait+Screen@10s /Select provider for Main/ +Enter +Wait+Screen@30s /Select model for Main.*alpha-models/ + +Escape +Wait+Screen@10s /Select provider for Main/ +Down +Enter +Wait+Screen@30s /Select model for Main.*bravo-models/ + # ─── Exit TUI ──────────────────────────────────────────────────────── Ctrl+Q Sleep 1s