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