fix(headless): S6 thread standardOutputIsTerminal instead of reading Console inside HeadlessProcessHost

HeadlessProcessHost read System.Console.IsOutputRedirected directly to
pick the console renderer's color mode, which only Program.cs (the
executable's own entry point) should ever touch -- the same reasoning
that already put the stdin probe there. Resolve
standardOutputIsTerminal next to the existing !Console.IsInputRedirected
probe in Program.cs and thread it through HeadlessEntryPoint.Run into
HeadlessProcessHost's constructor as a plain parameter.

StandardOutputIsTerminalParameterControlsColorNotTheRealConsole was
shown to fail with the parameter still unused (useColor still reading
the real Console.IsOutputRedirected, which the test host always
redirects): the standardOutputIsTerminal:true case expected dimmed
lifecycle output but got none, since the real console read forced
useColor=false regardless of what the test passed in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 08:05:37 +02:00
parent 3328b2f2b3
commit 2ff1e1228c
4 changed files with 72 additions and 5 deletions

View file

@ -47,7 +47,8 @@ internal static class HeadlessEntryPoint
TextWriter output,
TextWriter error,
CancellationToken cancellationToken,
bool standardInputIsTerminal = false)
bool standardInputIsTerminal = false,
bool standardOutputIsTerminal = false)
{
ArgumentNullException.ThrowIfNull(arguments);
ArgumentNullException.ThrowIfNull(standardInput);
@ -85,7 +86,8 @@ internal static class HeadlessEntryPoint
output,
directCredentials:
commandLine.DirectCredentials,
consoleEnabled: consoleEnabled);
consoleEnabled: consoleEnabled,
standardOutputIsTerminal: standardOutputIsTerminal);
return (int)host.RunAsync(cancellationToken)
.GetAwaiter()
.GetResult();

View file

@ -37,7 +37,8 @@ internal sealed class HeadlessProcessHost : IDisposable
TimeProvider? timeProvider = null,
IHeadlessProcessContentFactory? contentFactory = null,
HeadlessDirectCredentials? directCredentials = null,
bool consoleEnabled = false)
bool consoleEnabled = false,
bool standardOutputIsTerminal = false)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(paths);
@ -163,7 +164,7 @@ internal sealed class HeadlessProcessHost : IDisposable
HeadlessSessionHost session = _sessions[0];
var renderer = new HeadlessConsoleRenderer(
diagnostics,
useColor: !System.Console.IsOutputRedirected);
useColor: standardOutputIsTerminal);
consoleRendererSubscription =
session.Runtime.Subscribe(renderer);
HeadlessConsoleController controller = new(

View file

@ -28,7 +28,8 @@ try
Console.Out,
Console.Error,
cancellation.Token,
standardInputIsTerminal: !Console.IsInputRedirected);
standardInputIsTerminal: !Console.IsInputRedirected,
standardOutputIsTerminal: !Console.IsOutputRedirected);
}
finally
{

View file

@ -1,4 +1,5 @@
using System.Buffers.Binary;
using System.Diagnostics;
using System.Net;
using System.Text;
using AcDream.Core.Chat;
@ -8,6 +9,7 @@ using AcDream.Headless.Configuration;
using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics;
using AcDream.Headless.Hosting;
using AcDream.Headless.Platform;
using AcDream.Plugin.Abstractions;
using AcDream.Runtime;
using AcDream.Runtime.Chat;
@ -520,6 +522,48 @@ public sealed class HeadlessConsoleTests
Assert.Equal(["[vt] navigation route loaded"], printed);
}
// ── HeadlessProcessHost: end-to-end console wiring ───────────────────
/// <summary>
/// S6: <c>standardOutputIsTerminal</c> is threaded in as a constructor
/// parameter, not read from the real <c>System.Console</c> inside
/// <see cref="HeadlessProcessHost"/> — proven by flipping only the
/// parameter (this test process's OWN stdout is redirected by the test
/// host either way) and observing the renderer's dim-vs-plain choice
/// follow it.
/// </summary>
[Theory]
[InlineData(true, true)]
[InlineData(false, false)]
public async Task StandardOutputIsTerminalParameterControlsColorNotTheRealConsole(
bool standardOutputIsTerminal, bool expectDimmed)
{
var operations = new FixtureSessionOperations();
var configuration = new HeadlessConfiguration
{
Version = 1,
Sessions = [Descriptor()],
};
using var diagnostics = new StringWriter();
using var input = new System.IO.StringReader("/quit" + Environment.NewLine);
using var host = new HeadlessProcessHost(
configuration,
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
input,
diagnostics,
operations,
new FakeTimeProvider(),
directCredentials: new HeadlessDirectCredentials("account", "password"),
consoleEnabled: true,
standardOutputIsTerminal: standardOutputIsTerminal);
await host.RunAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10));
string text = diagnostics.ToString();
Assert.Contains("entered world", text);
Assert.Equal(expectDimmed, text.Contains("[2m"));
}
private static bool WaitForEndOfInput(HeadlessConsoleController controller) =>
controller.Reader.EndOfInput.Wait(TimeSpan.FromSeconds(5));
@ -625,4 +669,23 @@ public sealed class HeadlessConsoleTests
base.Dispose(disposing);
}
}
/// <summary>
/// S3/S6/S7: a real-clock <see cref="TimeProvider"/>, distinct from
/// <see cref="TimeProvider.System"/>, for a <see cref="HeadlessProcessHost"/>
/// integration test that runs the actual scheduler loop on its own
/// dedicated thread. Real elapsed time (not a manually-stepped fake) is
/// deliberate here: <see cref="HeadlessProcessHost.RunAsync"/> owns its
/// own background thread, and stepping a manual clock from the test
/// thread while that thread's scheduler loop waits on a
/// <see cref="ITimer"/> armed from the SAME provider would race the two
/// threads for no benefit — the default 15 ms turn period already makes
/// these tests fast.
/// </summary>
private sealed class FakeTimeProvider : TimeProvider
{
public override long GetTimestamp() => Stopwatch.GetTimestamp();
public override long TimestampFrequency => Stopwatch.Frequency;
}
}