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

@ -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) =>