refactor(headless): S5 poll SpewBox on the console pump instead of decorating one call

HeadlessConsoleChatFeedback wrapped RuntimeChatCommandFeedback per
SubmitConsoleLine call, so it only ever saw interface text produced by
the console's OWN typed line -- a server-driven refusal or a plugin's
own Log/interface-text write (RuntimeCommunicationState.AddText's
ClientLocal branch, called from anywhere else) never reached the
console at all, because that branch enqueues into SpewBoxState and
never touches ChatLog/RuntimeChatDelta.

Delete the decorator. HeadlessConsoleSpewBoxPump instead polls the SAME
SpewBoxState the graphical overlay's SpewBoxController.Tick already
reads, diffing against the previous visible snapshot so it prints only
newly-appeared entries. HeadlessProcessHost's ConsolePump now runs the
input drain and the SpewBox pump together each tick.
HeadlessSessionHost.SubmitConsoleLine drops its onInterfaceText
parameter -- it is just ChatCommandRouter.Submit against a plain
RuntimeChatCommandFeedback now, same as LoginCommandSequence.

N1: also corrected this method's own doc comment, which described
dispatch as "plugin verb registry first, then retail client/server
slash commands" -- the real ChatCommandRouter.Submit order is retail's
catalog, local /help, plugin verbs, the channel-tag fallback, an
explicit server command, then chat.

PumpPrintsInterfaceTextNotOriginatingFromTheConsole was shown to fail
against a no-op Pump() (mutation) -- the enqueued plugin-shaped line
never printed. UnknownVerbProducesTheSameInterfaceTextTheChatBoxShows
was reworked to assert against the real SpewBoxState directly instead
of the deleted decorator's callback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 08:01:24 +02:00
parent f0b7a136b2
commit 3328b2f2b3
5 changed files with 149 additions and 69 deletions

View file

@ -1,43 +0,0 @@
using AcDream.Runtime.Chat;
namespace AcDream.Headless.Hosting;
/// <summary>
/// Decorates the real <see cref="IChatCommandFeedback"/> a session already
/// builds for <c>LoginCommandSequence</c> so the console ALSO sees retail's
/// transient "interface text" — <c>ShowInterfaceText</c>'s SpewBox
/// (<c>ClientLocal</c>) path never touches <c>ChatLog</c> (see
/// <c>RuntimeCommunicationState.AddText</c>), so it never reaches the
/// console's <see cref="HeadlessConsoleRenderer"/> through the normal
/// <see cref="AcDream.Runtime.RuntimeChatDelta"/> event stream. The real
/// side effect (enqueuing into <c>SpewBoxState</c>, exactly what the
/// graphical chat box's own SpewBox overlay would show) still happens via
/// <paramref name="inner"/> — this only ADDS the console line, it never
/// replaces the canonical behavior.
/// </summary>
internal sealed class HeadlessConsoleChatFeedback : IChatCommandFeedback
{
private readonly IChatCommandFeedback _inner;
private readonly Action<string> _onInterfaceText;
internal HeadlessConsoleChatFeedback(
IChatCommandFeedback inner,
Action<string> onInterfaceText)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_onInterfaceText = onInterfaceText
?? throw new ArgumentNullException(nameof(onInterfaceText));
}
public string? LastIncomingTellSender => _inner.LastIncomingTellSender;
public string? LastOutgoingTellTarget => _inner.LastOutgoingTellTarget;
public void ShowInterfaceText(string text)
{
_inner.ShowInterfaceText(text);
_onInterfaceText(text);
}
public void ShowSystemMessage(string text) => _inner.ShowSystemMessage(text);
}

View file

@ -0,0 +1,63 @@
using AcDream.Core.Chat;
namespace AcDream.Headless.Hosting;
/// <summary>
/// S5 (2026-09-07 review round, docs/plans/2026-09-07-headless-console.md):
/// polls <see cref="SpewBoxState"/> on the console's own per-tick pump — the
/// SAME seam <c>AcDream.App.UI.SpewBoxController.Tick</c> drives for the
/// graphical overlay. Retail's transient "interface text"
/// (<see cref="RetailLogTextType.ClientLocal"/>, routed by
/// <c>RuntimeCommunicationState.AddText</c>) never touches
/// <c>RuntimeCommunicationState.Chat</c>/<c>RuntimeChatDelta</c>, so it is
/// otherwise invisible to a console that only observes the chat event
/// stream — this is true for EVERY producer of that text (a bad-args
/// refusal from the console's own submit, but also a server-driven refusal
/// or a plugin's own interface-text write), not just the console's own
/// submissions. This replaces the earlier per-call
/// <c>HeadlessConsoleChatFeedback</c> decorator, which only ever saw text
/// produced by the console's own <c>SubmitConsoleLine</c> calls.
/// </summary>
internal sealed class HeadlessConsoleSpewBoxPump
{
private readonly SpewBoxState _spewBox;
private readonly Func<double> _nowSeconds;
private readonly Action<string> _writeInterfaceText;
private SpewBoxEntry[] _lastSeen = [];
internal HeadlessConsoleSpewBoxPump(
SpewBoxState spewBox,
Func<double> nowSeconds,
Action<string> writeInterfaceText)
{
_spewBox = spewBox ?? throw new ArgumentNullException(nameof(spewBox));
_nowSeconds = nowSeconds
?? throw new ArgumentNullException(nameof(nowSeconds));
_writeInterfaceText = writeInterfaceText
?? throw new ArgumentNullException(nameof(writeInterfaceText));
}
/// <summary>
/// Drains any pending SpewBox text into the visible set (exactly
/// <see cref="SpewBoxState.Tick"/>'s contract — the same drain
/// <c>SpewBoxVM.Lines</c> performs for the graphical overlay) and prints
/// any entry that was not part of the previous call's visible snapshot.
/// </summary>
/// <remarks>
/// <see cref="SpewBoxState.Snapshot"/> is newest-first
/// (retail's <c>InsertItem(item, 0)</c>); this walks it back-to-front so
/// newly-visible entries print in the order they were actually
/// enqueued, not newest-first.
/// </remarks>
internal void Pump()
{
_spewBox.Tick(_nowSeconds());
SpewBoxEntry[] current = _spewBox.Snapshot();
for (int i = current.Length - 1; i >= 0; i--)
{
if (Array.IndexOf(_lastSeen, current[i]) < 0)
_writeInterfaceText(current[i].Text);
}
_lastSeen = current;
}
}

View file

@ -166,15 +166,30 @@ internal sealed class HeadlessProcessHost : IDisposable
useColor: !System.Console.IsOutputRedirected);
consoleRendererSubscription =
session.Runtime.Subscribe(renderer);
console = new HeadlessConsoleController(
HeadlessConsoleController controller = new(
standardInput,
diagnostics,
line => session.SubmitConsoleLine(
line,
renderer.WriteInterfaceText),
session.SubmitConsoleLine,
() => BuildStatusText(session),
_consoleQuitRequested);
session.ConsolePump = console.DrainDue;
// S5 (2026-09-07 review round): poll the SAME SpewBoxState
// seam the graphical overlay's SpewBoxController.Tick reads
// (RuntimeCommunicationState.AddText's ClientLocal branch —
// it never touches Chat/RuntimeChatDelta) so server- and
// plugin-driven interface text prints too, not only the
// console's own submissions. Replaces the earlier per-call
// HeadlessConsoleChatFeedback decorator, which only saw text
// produced by THIS console's own SubmitConsoleLine calls.
var spewPump = new HeadlessConsoleSpewBoxPump(
session.Runtime.CommunicationOwner.SpewBox,
() => session.Runtime.Clock.SimulationTimeSeconds,
renderer.WriteInterfaceText);
session.ConsolePump = () =>
{
controller.DrainDue();
spewPump.Pump();
};
console = controller;
}
_console = console;
_consoleRendererSubscription = consoleRendererSubscription;

View file

@ -601,22 +601,24 @@ internal sealed class HeadlessSessionHost : IDisposable
/// The headless console's ONE entry point for a typed line — the exact
/// pipeline <see cref="LoginCommandSequence"/> already submits through:
/// <see cref="ChatCommandRouter.Submit"/> against this host's retained
/// <see cref="_chatCommandSurface"/> (plugin verb registry first, then
/// retail client/server slash commands, then plain chat). Console
/// output for retail's transient interface text (bad-args refusals,
/// unknown-command text — never routed through
/// <see cref="_chatCommandSurface"/>. Dispatch order (matching
/// <see cref="ChatCommandRouter"/>'s own class doc): retail's client-
/// command catalog first, then the local <c>/help</c> presentation
/// command, then the plugin-verb registry, then the retail unregistered-
/// channel-tag fallback, then an explicit server command, then plain
/// chat. Retail's transient interface text (bad-args refusals, unknown-
/// command text — never routed through
/// <see cref="AcDream.Runtime.RuntimeChatDelta"/>, see
/// <see cref="HeadlessConsoleChatFeedback"/>'s own doc) is written via
/// <paramref name="onInterfaceText"/>.
/// <c>RuntimeCommunicationState.AddText</c>'s <c>ClientLocal</c> branch)
/// lands in the shared <see cref="RuntimeCommunicationState.SpewBox"/>
/// exactly like every other producer of that text; the console's own
/// per-tick pump polls it (see <c>HeadlessConsoleSpewBoxPump</c>)
/// instead of this call decorating its own feedback.
/// </summary>
internal SubmitOutcome SubmitConsoleLine(
string line,
Action<string> onInterfaceText) =>
internal SubmitOutcome SubmitConsoleLine(string line) =>
ChatCommandRouter.Submit(
line,
new HeadlessConsoleChatFeedback(
new RuntimeChatCommandFeedback(Runtime.CommunicationOwner),
onInterfaceText),
new RuntimeChatCommandFeedback(Runtime.CommunicationOwner),
_chatCommandSurface,
ChatChannelKind.Say);

View file

@ -383,7 +383,7 @@ public sealed class HeadlessConsoleTests
RuntimeSessionStartStatus.Connected,
host.Start().Status);
SubmitOutcome outcome = host.SubmitConsoleLine("/say hello", _ => { });
SubmitOutcome outcome = host.SubmitConsoleLine("/say hello");
Assert.Equal(SubmitOutcome.Sent, outcome);
byte[] body = Assert.Single(captured);
@ -409,7 +409,7 @@ public sealed class HeadlessConsoleTests
RuntimeSessionStartStatus.Connected,
host.Start().Status);
SubmitOutcome outcome = host.SubmitConsoleLine("hello", _ => { });
SubmitOutcome outcome = host.SubmitConsoleLine("hello");
Assert.Equal(SubmitOutcome.Sent, outcome);
byte[] body = Assert.Single(captured);
@ -439,7 +439,7 @@ public sealed class HeadlessConsoleTests
"vt",
command => received.Add(command));
SubmitOutcome outcome = host.SubmitConsoleLine("/vt start", _ => { });
SubmitOutcome outcome = host.SubmitConsoleLine("/vt start");
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
PluginCommand command = Assert.Single(received);
@ -448,6 +448,16 @@ public sealed class HeadlessConsoleTests
Assert.Empty(captured);
}
/// <summary>
/// S5 rework: <c>SubmitConsoleLine</c> no longer takes a per-call
/// interface-text callback — retail's transient interface text
/// (<c>ClientLocal</c>) lands in the shared
/// <see cref="AcDream.Core.Chat.SpewBoxState"/> exactly like every other
/// producer of that text (see <c>RuntimeCommunicationState.AddText</c>),
/// and the console's own per-tick pump polls it — proven directly here
/// against the real <see cref="AcDream.Core.Chat.SpewBoxState"/> rather
/// than a decorator only this call site could see.
/// </summary>
[Fact]
public void UnknownVerbProducesTheSameInterfaceTextTheChatBoxShows()
{
@ -461,20 +471,53 @@ public sealed class HeadlessConsoleTests
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
var interfaceText = new List<string>();
// A bare "/" is retail's degenerate-prefix case (no letter verb) —
// ChatCommandRouter refuses it locally instead of sending it to the
// server or speech (ChatCommandRouterTests.
// DegeneratePrefix_UnknownCommand_ShowsRefusal_ViaInterfaceTextSeam
// pins the exact same text for the graphical route).
SubmitOutcome outcome = host.SubmitConsoleLine(
"/",
interfaceText.Add);
SubmitOutcome outcome = host.SubmitConsoleLine("/");
Assert.Equal(SubmitOutcome.UnknownCommand, outcome);
string text = Assert.Single(interfaceText);
Assert.Contains("Unknown command:", text);
SpewBoxState spewBox = host.Runtime.CommunicationOwner.SpewBox;
spewBox.Tick(host.Runtime.Clock.SimulationTimeSeconds);
SpewBoxEntry entry = Assert.Single(spewBox.Snapshot());
Assert.Contains("Unknown command:", entry.Text);
}
// ── HeadlessConsoleSpewBoxPump: server/plugin-driven interface text ──
/// <summary>
/// S5: the pump must surface interface text that never went through the
/// console at all — a stand-in for a server- or plugin-driven
/// <c>ClientLocal</c> write reaching <c>RuntimeCommunicationState.AddText</c>
/// directly, exactly the case the deleted per-call
/// <c>HeadlessConsoleChatFeedback</c> decorator could never see (it only
/// ever wrapped THIS console's own <c>SubmitConsoleLine</c> feedback).
/// </summary>
[Fact]
public void PumpPrintsInterfaceTextNotOriginatingFromTheConsole()
{
var spewBox = new SpewBoxState();
var printed = new List<string>();
double now = 0d;
var pump = new HeadlessConsoleSpewBoxPump(spewBox, () => now, printed.Add);
// Simulates a plugin's own Log/interface-text write, or a server-
// driven refusal — never called HeadlessConsoleController.Handle or
// HeadlessSessionHost.SubmitConsoleLine.
spewBox.Enqueue("[vt] navigation route loaded");
pump.Pump();
Assert.Equal(["[vt] navigation route loaded"], printed);
// A second pump with nothing new enqueued must not reprint the
// still-visible entry.
now += 0.1d;
pump.Pump();
Assert.Equal(["[vt] navigation route loaded"], printed);
}
private static bool WaitForEndOfInput(HeadlessConsoleController controller) =>