diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleChatFeedback.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleChatFeedback.cs
deleted file mode 100644
index f84d9a0f3..000000000
--- a/src/AcDream.Headless/Hosting/HeadlessConsoleChatFeedback.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using AcDream.Runtime.Chat;
-
-namespace AcDream.Headless.Hosting;
-
-///
-/// Decorates the real a session already
-/// builds for LoginCommandSequence so the console ALSO sees retail's
-/// transient "interface text" — ShowInterfaceText's SpewBox
-/// (ClientLocal) path never touches ChatLog (see
-/// RuntimeCommunicationState.AddText), so it never reaches the
-/// console's through the normal
-/// event stream. The real
-/// side effect (enqueuing into SpewBoxState, exactly what the
-/// graphical chat box's own SpewBox overlay would show) still happens via
-/// — this only ADDS the console line, it never
-/// replaces the canonical behavior.
-///
-internal sealed class HeadlessConsoleChatFeedback : IChatCommandFeedback
-{
- private readonly IChatCommandFeedback _inner;
- private readonly Action _onInterfaceText;
-
- internal HeadlessConsoleChatFeedback(
- IChatCommandFeedback inner,
- Action 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);
-}
diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs
new file mode 100644
index 000000000..a901c89d9
--- /dev/null
+++ b/src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs
@@ -0,0 +1,63 @@
+using AcDream.Core.Chat;
+
+namespace AcDream.Headless.Hosting;
+
+///
+/// S5 (2026-09-07 review round, docs/plans/2026-09-07-headless-console.md):
+/// polls on the console's own per-tick pump — the
+/// SAME seam AcDream.App.UI.SpewBoxController.Tick drives for the
+/// graphical overlay. Retail's transient "interface text"
+/// (, routed by
+/// RuntimeCommunicationState.AddText) never touches
+/// RuntimeCommunicationState.Chat/RuntimeChatDelta, 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
+/// HeadlessConsoleChatFeedback decorator, which only ever saw text
+/// produced by the console's own SubmitConsoleLine calls.
+///
+internal sealed class HeadlessConsoleSpewBoxPump
+{
+ private readonly SpewBoxState _spewBox;
+ private readonly Func _nowSeconds;
+ private readonly Action _writeInterfaceText;
+ private SpewBoxEntry[] _lastSeen = [];
+
+ internal HeadlessConsoleSpewBoxPump(
+ SpewBoxState spewBox,
+ Func nowSeconds,
+ Action writeInterfaceText)
+ {
+ _spewBox = spewBox ?? throw new ArgumentNullException(nameof(spewBox));
+ _nowSeconds = nowSeconds
+ ?? throw new ArgumentNullException(nameof(nowSeconds));
+ _writeInterfaceText = writeInterfaceText
+ ?? throw new ArgumentNullException(nameof(writeInterfaceText));
+ }
+
+ ///
+ /// Drains any pending SpewBox text into the visible set (exactly
+ /// 's contract — the same drain
+ /// SpewBoxVM.Lines performs for the graphical overlay) and prints
+ /// any entry that was not part of the previous call's visible snapshot.
+ ///
+ ///
+ /// is newest-first
+ /// (retail's InsertItem(item, 0)); this walks it back-to-front so
+ /// newly-visible entries print in the order they were actually
+ /// enqueued, not newest-first.
+ ///
+ 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;
+ }
+}
diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
index 9c7d2043b..29f543e7e 100644
--- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
@@ -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;
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
index 9df6e3778..4453cbbea 100644
--- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
@@ -601,22 +601,24 @@ internal sealed class HeadlessSessionHost : IDisposable
/// The headless console's ONE entry point for a typed line — the exact
/// pipeline already submits through:
/// against this host's retained
- /// (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
+ /// . Dispatch order (matching
+ /// 's own class doc): retail's client-
+ /// command catalog first, then the local /help 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
- /// 's own doc) is written via
- /// .
+ /// RuntimeCommunicationState.AddText's ClientLocal branch)
+ /// lands in the shared
+ /// exactly like every other producer of that text; the console's own
+ /// per-tick pump polls it (see HeadlessConsoleSpewBoxPump)
+ /// instead of this call decorating its own feedback.
///
- internal SubmitOutcome SubmitConsoleLine(
- string line,
- Action onInterfaceText) =>
+ internal SubmitOutcome SubmitConsoleLine(string line) =>
ChatCommandRouter.Submit(
line,
- new HeadlessConsoleChatFeedback(
- new RuntimeChatCommandFeedback(Runtime.CommunicationOwner),
- onInterfaceText),
+ new RuntimeChatCommandFeedback(Runtime.CommunicationOwner),
_chatCommandSurface,
ChatChannelKind.Say);
diff --git a/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs b/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs
index f34145e27..e81dd5e69 100644
--- a/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs
@@ -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);
}
+ ///
+ /// S5 rework: SubmitConsoleLine no longer takes a per-call
+ /// interface-text callback — retail's transient interface text
+ /// (ClientLocal) lands in the shared
+ /// exactly like every other
+ /// producer of that text (see RuntimeCommunicationState.AddText),
+ /// and the console's own per-tick pump polls it — proven directly here
+ /// against the real rather
+ /// than a decorator only this call site could see.
+ ///
[Fact]
public void UnknownVerbProducesTheSameInterfaceTextTheChatBoxShows()
{
@@ -461,20 +471,53 @@ public sealed class HeadlessConsoleTests
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
- var interfaceText = new List();
// 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 ──
+
+ ///
+ /// S5: the pump must surface interface text that never went through the
+ /// console at all — a stand-in for a server- or plugin-driven
+ /// ClientLocal write reaching RuntimeCommunicationState.AddText
+ /// directly, exactly the case the deleted per-call
+ /// HeadlessConsoleChatFeedback decorator could never see (it only
+ /// ever wrapped THIS console's own SubmitConsoleLine feedback).
+ ///
+ [Fact]
+ public void PumpPrintsInterfaceTextNotOriginatingFromTheConsole()
+ {
+ var spewBox = new SpewBoxState();
+ var printed = new List();
+ 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) =>