From 9a55ac272782871414626d28cdfff421809bb14d Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 22 Sep 2026 11:54:57 -0700 Subject: [PATCH 1/5] fix: preserve Unicode capability for gateway spinner Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fd24d904-826b-4c4e-bf84-ad9427d8695d --- src/OpenClaw.Launcher/ClawCtlConsole.cs | 9 ++++--- .../Gateway/AgentPostflight.cs | 1 + .../ClawCtlConsoleTests.cs | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index b5bf5305..27444755 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -121,6 +121,7 @@ internal static Task NarrateGatewayStartAsync( bool useColor, bool narrate, bool outputIsInteractive, + bool useUnicode, Func, Task> start) => NarrateAsync( output, @@ -128,7 +129,8 @@ internal static Task NarrateGatewayStartAsync( narrate, outputIsInteractive, GatewayStartProgress.Initial, - start); + start, + useUnicode); internal static void WriteGatewayStartWarning( TextWriter output, @@ -281,7 +283,8 @@ private static async Task NarrateAsync( bool narrate, bool outputIsInteractive, ClawCtlProgress initial, - Func, Task> operation) + Func, Task> operation, + bool? useUnicode = null) { ArgumentNullException.ThrowIfNull(output); ArgumentNullException.ThrowIfNull(initial); @@ -311,7 +314,7 @@ await output.WriteLineAsync($"{new string(' ', Indent)}{initial.Message}") IAnsiConsole console = CreateConsole( output, useColor, - SupportsUnicode(output), + useUnicode ?? SupportsUnicode(output), ResolveWidth(output), InteractionSupport.Yes); return await NarrateWithStatusAsync(console, initial, operation) diff --git a/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs b/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs index 61b50bdb..a2714dc3 100644 --- a/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs +++ b/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs @@ -85,6 +85,7 @@ public static AgentPostflight Create( output.UseColor, interactive, output.UseLiveRendering, + output.UseUnicode, start).ConfigureAwait(false); }, (progress, onRunningUnderLock) => gateway.StartWithLockAlreadyHeldAsync( diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index a1512ea8..c08273f2 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -240,6 +240,7 @@ public async Task GatewayNarrationUsesKnownInteractiveProcessOutput() useColor: false, narrate: true, outputIsInteractive: true, + useUnicode: true, progress => { progress.Report(new GatewayStartProgress( @@ -260,6 +261,32 @@ public async Task GatewayNarrationUsesKnownInteractiveProcessOutput() StringComparison.Ordinal); } + [Fact] + public async Task GatewayNarrationPreservesUnicodeCapabilityForNonConsoleOutput() + { + using var output = new StringWriter(); + + _ = await ClawCtlConsole.NarrateGatewayStartAsync( + output, + useColor: true, + narrate: true, + outputIsInteractive: true, + useUnicode: true, + progress => + { + progress.Report(new GatewayStartProgress( + GatewayStartStage.Launching, + "Launching the gateway.")); + return Task.FromResult(new GatewayStartResult( + GatewayState.Running, + new GatewayRecord(), + AlreadyRunning: false, + "The gateway is running.")); + }); + + Assert.Contains("\u280b", output.ToString(), StringComparison.Ordinal); + } + [Fact] public void DiagnosticsBundlePathRemainsAnExactStandaloneLine() { From 4ce6e92ed8156e03f97a4b4c06090ab9885022d3 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 22 Sep 2026 17:08:00 -0700 Subject: [PATCH 2/5] fix: report automatic gateway start outcome Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fd24d904-826b-4c4e-bf84-ad9427d8695d --- docs/clawctl-output-style.md | 13 +- src/OpenClaw.Launcher/ClawCtlConsole.cs | 115 ++++++++++++++++-- .../Gateway/AgentGatewayGuidance.cs | 10 +- .../Gateway/AgentPostflight.cs | 18 ++- .../ClawCtlConsoleTests.cs | 101 +++++++++++++++ .../Gateway/AgentGatewayGuidanceTests.cs | 95 ++++++++++++++- 6 files changed, 329 insertions(+), 23 deletions(-) diff --git a/docs/clawctl-output-style.md b/docs/clawctl-output-style.md index 81ef31c3..77472dcb 100644 --- a/docs/clawctl-output-style.md +++ b/docs/clawctl-output-style.md @@ -26,10 +26,17 @@ should describe that environment in terms an operator can act on, while gateway start on standard error. Do nothing for non-zero child exits, redirected output, non-eligible readiness, or `Starting`, `Unhealthy`, and `Unknown` gateway states; acknowledge an observed running gateway. -- A failed or unverified automatic gateway start must not change the OpenClaw - exit code. Write a warning to standard error, followed by +- After live gateway narration clears, write one durable outcome line. Unicode + output starts with the crab identity and uses a check mark for a verified + listener, an exclamation mark for a still-starting or unverified launch, and + a cross for a definite stop or handled start failure. Non-Unicode output uses + `[ok]`, `[!]`, or `[x]` without the crab. +- A still-starting or unverified automatic gateway start directs the user to + `clawctl gateway-service status`; do not encourage a second start while an + owned process may still be alive. A definite stop or handled failure writes `clawctl gateway-service start` on its own line so width-constrained wrapping - cannot break the retry command. Leave the guidance unacknowledged so a later + cannot break the retry command. These advisory outcomes must not change the + OpenClaw exit code. Leave unsuccessful guidance unacknowledged so a later eligible launch retries. `CLAWCTL_AUTO_GATEWAY_START=0` (or `false`, `no`, or `off`) restores the start hint. - Keep a successful `clawctl pwsh` launch silent so the user reaches the shell diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index 27444755..60b938b7 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -132,7 +132,48 @@ internal static Task NarrateGatewayStartAsync( start, useUnicode); - internal static void WriteGatewayStartWarning( + internal static void WriteGatewayStartOutcome( + TextWriter output, + GatewayStartResult result, + bool useColor = false, + bool? useUnicode = null) + { + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(result); + + bool unicode = useUnicode ?? SupportsUnicode(output); + (StatusKind kind, string word, string trailing) = result.State switch + { + GatewayState.Running => ( + StatusKind.Success, + "running", + $" on {GatewayController.DescribePorts(result.Record)}."), + GatewayState.Starting => ( + StatusKind.Warning, + "still starting", + $". {result.Message}"), + GatewayState.Unknown => ( + StatusKind.Warning, + "unverified", + $". {result.Message}"), + _ => ( + StatusKind.Failure, + "failed", + $". {result.Message}") + }; + + RenderGatewayOutcome(output, kind, word, trailing, useColor, unicode); + if (result.State is GatewayState.Starting or GatewayState.Unknown) + { + WriteGatewayStatusGuidance(output, useColor, unicode); + } + else if (result.State != GatewayState.Running) + { + WriteGatewayRetryGuidance(output, useColor, unicode); + } + } + + internal static void WriteGatewayStartFailure( TextWriter output, string detail, bool useColor = false, @@ -142,24 +183,74 @@ internal static void WriteGatewayStartWarning( ArgumentException.ThrowIfNullOrWhiteSpace(detail); bool unicode = useUnicode ?? SupportsUnicode(output); + RenderGatewayOutcome( + output, + StatusKind.Failure, + "failed", + $". {detail}", + useColor, + unicode); + WriteGatewayRetryGuidance(output, useColor, unicode); + } + + private static void RenderGatewayOutcome( + TextWriter output, + StatusKind kind, + string word, + string trailing, + bool useColor, + bool unicode) + { + Style style = kind switch + { + StatusKind.Success => SuccessStyle, + StatusKind.Warning => WarningStyle, + StatusKind.Failure => FailureStyle, + _ => MutedStyle + }; var paragraph = new Paragraph(); if (unicode) { - paragraph.Append($"{IdentityMark} ", WarningStyle); + paragraph.Append($"{IdentityMark} ", style); + } + + string? mark = Mark(kind, unicode); + if (mark is not null) + { + paragraph.Append(mark, style); + paragraph.Append(" "); } - paragraph.Append("Warning:", WarningStyle); - paragraph.Append($" {detail}"); + paragraph.Append("Gateway: "); + paragraph.Append(word, style); + paragraph.Append(trailing); Render(output, paragraph, useColor, unicode); + } - // The retry command is rendered separately so the width-constrained - // paragraph above cannot wrap it across lines, which would make it - // unusable to copy. - var retry = new Paragraph(); - retry.Append("Retry with "); - retry.Append("clawctl gateway-service start", AccentStyle); - retry.Append("."); - Render(output, retry, useColor, unicode); + private static void WriteGatewayStatusGuidance( + TextWriter output, + bool useColor, + bool unicode) + { + var guidance = new Paragraph(); + guidance.Append("Check with "); + guidance.Append("clawctl gateway-service status", AccentStyle); + guidance.Append("."); + Render(output, guidance, useColor, unicode); + } + + private static void WriteGatewayRetryGuidance( + TextWriter output, + bool useColor, + bool unicode) + { + // Keep the retry command on its own line so terminal wrapping cannot + // make the next action difficult to copy. + var guidance = new Paragraph(); + guidance.Append("Retry with "); + guidance.Append("clawctl gateway-service start", AccentStyle); + guidance.Append("."); + Render(output, guidance, useColor, unicode); } internal static void WriteVersion(TextWriter output, bool useColor = false) diff --git a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs index 9dc395c7..b6ec8b89 100644 --- a/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs +++ b/src/OpenClaw.Launcher/Gateway/AgentGatewayGuidance.cs @@ -35,6 +35,7 @@ private readonly Func< IProgress, Action, Task> _startGatewayUnderLock; + private readonly Action _writeGatewayStartOutcome; private readonly Action _writeGatewayStartFailure; private readonly Func _readEnvironmentVariable; @@ -55,6 +56,7 @@ public AgentGatewayGuidance( IProgress, Action, Task>? startGateway = null, + Action? writeGatewayStartOutcome = null, Action? writeGatewayStartFailure = null, Func? readEnvironmentVariable = null) { @@ -71,6 +73,7 @@ public AgentGatewayGuidance( start(new Progress())); _startGatewayUnderLock = startGateway ?? ((_, _) => throw new InvalidOperationException( "Gateway start is not configured.")); + _writeGatewayStartOutcome = writeGatewayStartOutcome ?? ((_, _) => { }); _writeGatewayStartFailure = writeGatewayStartFailure ?? ((writer, message) => { @@ -211,12 +214,7 @@ private async Task StartGatewayAsync(string logonSessionId, TextWriter error) logonSessionId, GatewayGuidanceAcknowledgement.GatewayObservedRunning))) .ConfigureAwait(false); - if (result.State != GatewayState.Running && !result.AlreadyRunning) - { - WriteGatewayStartFailure( - error, - $"Gateway start finished in {result.State} state: {result.Message}"); - } + _writeGatewayStartOutcome(error, result); } catch (Exception exception) when ( exception is MxcException or SessionException or SessionLaunchException or diff --git a/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs b/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs index a2714dc3..52160fe2 100644 --- a/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs +++ b/src/OpenClaw.Launcher/Gateway/AgentPostflight.cs @@ -93,6 +93,22 @@ public static AgentPostflight Create( CancellationToken.None, progress, onRunningUnderLock), + (target, result) => + { + using GatewayOutput output = PrepareGatewayOutput( + target, + interactive, + readEnvironmentVariable, + log, + errorIsProcessConsoleWriter, + errorIsInteractive, + supportsUnicode); + ClawCtlConsole.WriteGatewayStartOutcome( + target, + result, + output.UseColor, + output.UseUnicode); + }, (target, detail) => { using GatewayOutput output = PrepareGatewayOutput( @@ -103,7 +119,7 @@ public static AgentPostflight Create( errorIsProcessConsoleWriter, errorIsInteractive, supportsUnicode); - ClawCtlConsole.WriteGatewayStartWarning( + ClawCtlConsole.WriteGatewayStartFailure( target, detail, output.UseColor, diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index c08273f2..6fd8c6af 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -287,6 +287,107 @@ public async Task GatewayNarrationPreservesUnicodeCapabilityForNonConsoleOutput( Assert.Contains("\u280b", output.ToString(), StringComparison.Ordinal); } + [Theory] + [InlineData(true, "\U0001f980 \u2713 Gateway: running on port 18789.")] + [InlineData(false, "[ok] Gateway: running on port 18789.")] + public void GatewayStartOutcomeReportsVerifiedSuccess( + bool useUnicode, + string expected) + { + using var output = new StringWriter(); + + ClawCtlConsole.WriteGatewayStartOutcome( + output, + new GatewayStartResult( + GatewayState.Running, + new GatewayRecord { ObservedPorts = [18789] }, + AlreadyRunning: false, + "The gateway is running."), + useUnicode: useUnicode); + + Assert.Equal(expected + Environment.NewLine, output.ToString()); + } + + [Theory] + [InlineData((int)GatewayState.Starting, "still starting")] + [InlineData((int)GatewayState.Unknown, "unverified")] + public void UncertainGatewayOutcomeDirectsTheUserToStatus( + int stateValue, + string expectedState) + { + using var output = new StringWriter(); + + ClawCtlConsole.WriteGatewayStartOutcome( + output, + new GatewayStartResult( + (GatewayState)stateValue, + new GatewayRecord(), + AlreadyRunning: false, + "The gateway [state] could not be verified."), + useUnicode: true); + + string rendered = output.ToString(); + Assert.Contains( + $"\U0001f980 ! Gateway: {expectedState}. " + + "The gateway [state] could not be verified.", + rendered, + StringComparison.Ordinal); + Assert.Contains( + "Check with clawctl gateway-service status.", + rendered, + StringComparison.Ordinal); + Assert.DoesNotContain( + "clawctl gateway-service start", + rendered, + StringComparison.Ordinal); + } + + [Fact] + public void StoppedGatewayOutcomeReportsFailureAndRetry() + { + using var output = new StringWriter(); + + ClawCtlConsole.WriteGatewayStartOutcome( + output, + new GatewayStartResult( + GatewayState.Stopped, + new GatewayRecord(), + AlreadyRunning: false, + "The gateway exited during startup."), + useUnicode: true); + + string rendered = output.ToString(); + Assert.Contains( + "\U0001f980 \u2717 Gateway: failed. The gateway exited during startup.", + rendered, + StringComparison.Ordinal); + Assert.Contains( + "Retry with clawctl gateway-service start.", + rendered, + StringComparison.Ordinal); + } + + [Fact] + public void GatewayStartExceptionReportsFailureAndRetryWithoutParsingMarkup() + { + using var output = new StringWriter(); + + ClawCtlConsole.WriteGatewayStartFailure( + output, + @"Gateway [launch] failed at C:\work.", + useUnicode: false); + + string rendered = output.ToString(); + Assert.Contains( + @"[x] Gateway: failed. Gateway [launch] failed at C:\work.", + rendered, + StringComparison.Ordinal); + Assert.Contains( + "Retry with clawctl gateway-service start.", + rendered, + StringComparison.Ordinal); + } + [Fact] public void DiagnosticsBundlePathRemainsAnExactStandaloneLine() { diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs index 54a562e4..424aa480 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/AgentGatewayGuidanceTests.cs @@ -31,7 +31,12 @@ private AgentGatewayGuidance Create( ISessionLock? lifecycleLock = null, Func>? checkReadiness = null, Func>? getGatewayStatus = null, - GatewayGuidanceStateStore? stateOverride = null) + GatewayGuidanceStateStore? stateOverride = null, + Func< + TextWriter, + Func, Task>, + Task>? narrateGatewayStart = null, + Action? writeGatewayStartOutcome = null) { store = stateOverride ?? new GatewayGuidanceStateStore( Path.Combine(_root, $"gateway-guidance-{Guid.NewGuid():N}.json")); @@ -62,6 +67,7 @@ private AgentGatewayGuidance Create( _log.Add, new FixedTimeProvider( new DateTimeOffset(2026, 9, 18, 18, 0, 0, TimeSpan.Zero)), + narrateGatewayStart: narrateGatewayStart, startGateway: startGateway ?? ((_, onRunningUnderLock) => { startCalled?.Invoke(); @@ -73,6 +79,7 @@ private AgentGatewayGuidance Create( onRunningUnderLock(result); return Task.FromResult(result); }), + writeGatewayStartOutcome: writeGatewayStartOutcome, readEnvironmentVariable: readEnvironmentVariable ?? (_ => null)); } @@ -123,6 +130,92 @@ public async Task EligibleGatewayStartsInsideTheLifecycleLock() Assert.Equal(0, lifecycleLock.HeldCount); } + [Fact] + public async Task SuccessfulStartWritesTheReturnedOutcome() + { + GatewayStartResult? written = null; + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out _, + writeGatewayStartOutcome: (_, result) => written = result); + + await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); + + Assert.NotNull(written); + Assert.Equal(GatewayState.Running, written.State); + } + + [Theory] + [InlineData((int)GatewayState.Starting)] + [InlineData((int)GatewayState.Unknown)] + [InlineData((int)GatewayState.Stopped)] + public async Task NonRunningStartWritesOutcomeWithoutAcknowledging(int stateValue) + { + GatewayState state = (GatewayState)stateValue; + GatewayStartResult? written = null; + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out GatewayGuidanceStateStore store, + startGateway: (_, _) => Task.FromResult(new GatewayStartResult( + state, + new GatewayRecord(), + AlreadyRunning: false, + $"The gateway finished in {state} state.")), + writeGatewayStartOutcome: (_, result) => written = result); + + await guidance.EvaluateAsync(0, interactive: true, TextWriter.Null); + + Assert.NotNull(written); + Assert.Equal(state, written.State); + Assert.False(store.IsAcknowledged("logon-a")); + } + + [Fact] + public async Task SuccessfulNarratedStartLeavesDurableOutcome() + { + AgentGatewayGuidance guidance = Create( + SessionConfigReadinessState.StartupEligible, + GatewayState.NotStarted, + out _, + startGateway: (progress, onRunningUnderLock) => + { + progress.Report(new GatewayStartProgress( + GatewayStartStage.Listening, + "The gateway is listening on port 18789.")); + var result = new GatewayStartResult( + GatewayState.Running, + new GatewayRecord { ObservedPorts = [18789] }, + AlreadyRunning: false, + "The gateway is running on port 18789."); + onRunningUnderLock(result); + return Task.FromResult(result); + }, + narrateGatewayStart: (writer, start) => + ClawCtlConsole.NarrateGatewayStartAsync( + writer, + useColor: false, + narrate: true, + outputIsInteractive: true, + useUnicode: true, + start), + writeGatewayStartOutcome: (writer, result) => + ClawCtlConsole.WriteGatewayStartOutcome( + writer, + result, + useColor: false, + useUnicode: true)); + var error = new StringWriter(); + + await guidance.EvaluateAsync(0, interactive: true, error); + + Assert.Contains( + "\U0001f980 \u2713 Gateway: running on port 18789.", + error.ToString(), + StringComparison.Ordinal); + } + [Fact] public async Task LateAcknowledgementAfterStopSuppressesPendingAutomaticStart() { From 141aa050c568dfd85a79f09218abea6016d02039 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 22 Sep 2026 17:24:37 -0700 Subject: [PATCH 3/5] test: synchronize Unicode spinner observation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fd24d904-826b-4c4e-bf84-ad9427d8695d --- .../ClawCtlConsoleTests.cs | 65 +++++++++++++++++-- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index 6fd8c6af..1dbb7aba 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -264,7 +264,7 @@ public async Task GatewayNarrationUsesKnownInteractiveProcessOutput() [Fact] public async Task GatewayNarrationPreservesUnicodeCapabilityForNonConsoleOutput() { - using var output = new StringWriter(); + using var output = new SignalingTextWriter("Launching the gateway."); _ = await ClawCtlConsole.NarrateGatewayStartAsync( output, @@ -272,19 +272,22 @@ public async Task GatewayNarrationPreservesUnicodeCapabilityForNonConsoleOutput( narrate: true, outputIsInteractive: true, useUnicode: true, - progress => + async progress => { progress.Report(new GatewayStartProgress( GatewayStartStage.Launching, "Launching the gateway.")); - return Task.FromResult(new GatewayStartResult( + await output.SignalObserved.ConfigureAwait(false); + return new GatewayStartResult( GatewayState.Running, new GatewayRecord(), AlreadyRunning: false, - "The gateway is running.")); + "The gateway is running."); }); - Assert.Contains("\u280b", output.ToString(), StringComparison.Ordinal); + Assert.Contains( + output.GetText(), + static character => character is >= '\u2800' and <= '\u28ff'); } [Theory] @@ -475,4 +478,56 @@ public void ColorChangesOnlyTerminalFormatting() Regex.Replace(colored.ToString(), "\u001b\\[[0-9;]*m", string.Empty)); } + private sealed class SignalingTextWriter(string signal) : StringWriter + { + private readonly Lock _gate = new(); + private readonly TaskCompletionSource _signalObserved = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task SignalObserved => _signalObserved.Task; + + public string GetText() + { + lock (_gate) + { + return base.ToString(); + } + } + + public override void Write(char value) + { + lock (_gate) + { + base.Write(value); + SignalIfObserved(); + } + } + + public override void Write(string? value) + { + lock (_gate) + { + base.Write(value); + SignalIfObserved(); + } + } + + public override void Write(char[] buffer, int index, int count) + { + lock (_gate) + { + base.Write(buffer, index, count); + SignalIfObserved(); + } + } + + private void SignalIfObserved() + { + if (GetStringBuilder().ToString().Contains(signal, StringComparison.Ordinal)) + { + _signalObserved.TrySetResult(); + } + } + } + } From 0f0d4aa496e27ae4d692470224fc98d9a9835cdf Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 22 Sep 2026 17:29:57 -0700 Subject: [PATCH 4/5] test: wait for rendered spinner frame Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fd24d904-826b-4c4e-bf84-ad9427d8695d --- .../ClawCtlConsoleTests.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index 1dbb7aba..7315a7a7 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -264,7 +264,7 @@ public async Task GatewayNarrationUsesKnownInteractiveProcessOutput() [Fact] public async Task GatewayNarrationPreservesUnicodeCapabilityForNonConsoleOutput() { - using var output = new SignalingTextWriter("Launching the gateway."); + using var output = new SpinnerObservingTextWriter(); _ = await ClawCtlConsole.NarrateGatewayStartAsync( output, @@ -277,7 +277,7 @@ public async Task GatewayNarrationPreservesUnicodeCapabilityForNonConsoleOutput( progress.Report(new GatewayStartProgress( GatewayStartStage.Launching, "Launching the gateway.")); - await output.SignalObserved.ConfigureAwait(false); + await output.SpinnerFrameObserved.ConfigureAwait(false); return new GatewayStartResult( GatewayState.Running, new GatewayRecord(), @@ -478,13 +478,13 @@ public void ColorChangesOnlyTerminalFormatting() Regex.Replace(colored.ToString(), "\u001b\\[[0-9;]*m", string.Empty)); } - private sealed class SignalingTextWriter(string signal) : StringWriter + private sealed class SpinnerObservingTextWriter : StringWriter { private readonly Lock _gate = new(); - private readonly TaskCompletionSource _signalObserved = + private readonly TaskCompletionSource _spinnerFrameObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); - public Task SignalObserved => _signalObserved.Task; + public Task SpinnerFrameObserved => _spinnerFrameObserved.Task; public string GetText() { @@ -523,9 +523,11 @@ public override void Write(char[] buffer, int index, int count) private void SignalIfObserved() { - if (GetStringBuilder().ToString().Contains(signal, StringComparison.Ordinal)) + string rendered = GetStringBuilder().ToString(); + if (rendered.Any(static character => + character is >= '\u2800' and <= '\u28ff' or '-' or '\\' or '|' or '/')) { - _signalObserved.TrySetResult(); + _spinnerFrameObserved.TrySetResult(); } } } From 530ae7cf31a2c9338dc96ae73301da6009bd90cc Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 22 Sep 2026 18:02:34 -0700 Subject: [PATCH 5/5] test: observe project spinner catalog Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fd24d904-826b-4c4e-bf84-ad9427d8695d --- .../ClawCtlConsoleTests.cs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index 23445874..d7351d7f 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -271,7 +271,17 @@ public async Task GatewayNarrationUsesKnownInteractiveProcessOutput() [Fact] public async Task GatewayNarrationPreservesUnicodeCapabilityForNonConsoleOutput() { - using var output = new SpinnerObservingTextWriter(); + string[] unicodeFrames = + [ + .. ClawCtlSpinner.Scuttle.Frames, + .. ClawCtlSpinner.Bubbles.Frames, + .. ClawCtlSpinner.TidePulse.Frames, + ]; + using var output = new SpinnerObservingTextWriter( + [ + .. unicodeFrames, + .. ClawCtlSpinner.Ascii.Frames, + ]); _ = await ClawCtlConsole.NarrateGatewayStartAsync( output, @@ -292,9 +302,10 @@ public async Task GatewayNarrationPreservesUnicodeCapabilityForNonConsoleOutput( "The gateway is running."); }); + string rendered = output.GetText(); Assert.Contains( - output.GetText(), - static character => character is >= '\u2800' and <= '\u28ff'); + unicodeFrames, + frame => rendered.Contains(frame, StringComparison.Ordinal)); } [Theory] @@ -485,7 +496,8 @@ public void ColorChangesOnlyTerminalFormatting() Regex.Replace(colored.ToString(), "\u001b\\[[0-9;]*m", string.Empty)); } - private sealed class SpinnerObservingTextWriter : StringWriter + private sealed class SpinnerObservingTextWriter(IReadOnlyCollection spinnerFrames) + : StringWriter { private readonly Lock _gate = new(); private readonly TaskCompletionSource _spinnerFrameObserved = @@ -531,8 +543,8 @@ public override void Write(char[] buffer, int index, int count) private void SignalIfObserved() { string rendered = GetStringBuilder().ToString(); - if (rendered.Any(static character => - character is >= '\u2800' and <= '\u28ff' or '-' or '\\' or '|' or '/')) + if (spinnerFrames.Any( + frame => rendered.Contains(frame, StringComparison.Ordinal))) { _spinnerFrameObserved.TrySetResult(); }