diff --git a/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs b/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs
new file mode 100644
index 00000000..4d08d587
--- /dev/null
+++ b/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs
@@ -0,0 +1,447 @@
+using System.Buffers.Binary;
+using System.Net;
+using System.Text;
+using AcDream.Core.Chat;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Headless.Configuration;
+using AcDream.Headless.Credentials;
+using AcDream.Headless.Diagnostics;
+using AcDream.Headless.Hosting;
+using AcDream.Plugin.Abstractions;
+using AcDream.Runtime;
+using AcDream.Runtime.Chat;
+using AcDream.Runtime.Session;
+
+namespace AcDream.Headless.Tests;
+
+///
+/// docs/plans/2026-09-07-headless-console.md — the headless interactive
+/// console. Two layers: /
+/// tested in isolation (no live
+/// server, no ), then
+/// tested against a real
+/// host wired to — the same
+/// no-network fixture pattern HeadlessSessionHostTests already uses
+/// for LoginCommandSequence, proving the console reuses the EXACT
+/// same pipeline rather than a second parser.
+///
+public sealed class HeadlessConsoleTests
+{
+ // ── HeadlessConsoleOptions (typed option resolution) ─────────────────
+
+ [Theory]
+ [InlineData(true, "0", false, true)] // CLI flag always wins
+ [InlineData(false, "1", false, true)] // env var wins over terminal default
+ [InlineData(false, "0", true, true)] // env var "0" does not disable the terminal default
+ [InlineData(false, null, true, true)] // no flag/env -> terminal-shaped default (on)
+ [InlineData(false, null, false, false)] // no flag/env -> terminal-shaped default (off)
+ public void ResolvePrefersFlagThenEnvironmentThenTerminalDefault(
+ bool commandLineFlag,
+ string? environmentValue,
+ bool standardInputIsTerminal,
+ bool expected)
+ {
+ bool resolved = HeadlessConsoleOptions.Resolve(
+ commandLineFlag,
+ _ => environmentValue,
+ standardInputIsTerminal);
+
+ Assert.Equal(expected, resolved);
+ }
+
+ [Fact]
+ public void CommandLineParsesTheBareConsoleFlag()
+ {
+ HeadlessCommandLine parsed = HeadlessCommandLine.Parse(
+ ["run", "--config", "bot.json", "--console"]);
+
+ Assert.True(parsed.Console);
+ Assert.Equal("bot.json", parsed.ConfigurationPath);
+ }
+
+ [Fact]
+ public void CommandLineWithoutTheFlagDefaultsConsoleOff()
+ {
+ HeadlessCommandLine parsed = HeadlessCommandLine.Parse(
+ ["run", "--config", "bot.json"]);
+
+ Assert.False(parsed.Console);
+ }
+
+ // ── HeadlessConsoleInputReader: reader-thread/ordering ───────────────
+
+ ///
+ /// The required reader-thread test: lines produced by the background
+ /// thread are drained, in FIFO order, entirely on the CALLING thread.
+ /// The reader thread itself never runs anything beyond
+ /// ConcurrentQueue.Enqueue — there is no dispatch code it could
+ /// execute — so this also structurally proves "never executed on the
+ /// reader thread," not just orders the output.
+ ///
+ [Fact]
+ public void LinesQueuedByTheReaderThreadDrainInOrderOnTheCallingThread()
+ {
+ using var input = new System.IO.StringReader(
+ "one" + Environment.NewLine
+ + "two" + Environment.NewLine
+ + "three" + Environment.NewLine);
+ using var reader = new HeadlessConsoleInputReader(input);
+
+ Assert.True(
+ reader.EndOfInput.Wait(TimeSpan.FromSeconds(5)),
+ "the reader thread never reached EOF");
+
+ int callingThread = Environment.CurrentManagedThreadId;
+ var drained = new List();
+ while (reader.TryDequeue(out string line))
+ {
+ drained.Add(line);
+ // Proves the dequeue (and everything a caller does with the
+ // line) runs on THIS thread, not the reader thread.
+ Assert.Equal(callingThread, Environment.CurrentManagedThreadId);
+ }
+
+ Assert.Equal(["one", "two", "three"], drained);
+ }
+
+ // ── HeadlessConsoleController: /quit, /status, dispatch ordering ─────
+
+ [Fact]
+ public void ControllerDrainsEveryLineQueuedSinceTheLastTickInOrderOnOneCall()
+ {
+ // Simulates "input queued during a busy tick": every line is
+ // enqueued by the reader thread before DrainDue is ever called —
+ // one DrainDue call must still process all of them, in order.
+ using var input = new System.IO.StringReader(
+ "alpha" + Environment.NewLine
+ + "beta" + Environment.NewLine
+ + "gamma" + Environment.NewLine);
+ var handled = new List();
+ using var quit = new CancellationTokenSource();
+ using var controller = new HeadlessConsoleController(
+ input,
+ TextWriter.Null,
+ line =>
+ {
+ handled.Add(line);
+ return SubmitOutcome.Sent;
+ },
+ () => string.Empty,
+ quit);
+
+ Assert.True(
+ WaitForEndOfInput(controller),
+ "the reader thread never reached EOF");
+ controller.DrainDue();
+
+ Assert.Equal(["alpha", "beta", "gamma"], handled);
+ Assert.Equal(3, controller.LastDrainCount);
+
+ // A second drain with nothing queued does nothing — proves DrainDue
+ // does not re-process already-handled lines.
+ controller.DrainDue();
+ Assert.Equal(["alpha", "beta", "gamma"], handled);
+ Assert.Equal(0, controller.LastDrainCount);
+ }
+
+ [Fact]
+ public void QuitRequestsCancellationAndNeverReachesSubmit()
+ {
+ using var input = new System.IO.StringReader("/quit" + Environment.NewLine);
+ var submitted = new List();
+ var output = new StringWriter();
+ using var quit = new CancellationTokenSource();
+ using var controller = new HeadlessConsoleController(
+ input,
+ output,
+ line =>
+ {
+ submitted.Add(line);
+ return SubmitOutcome.Sent;
+ },
+ () => string.Empty,
+ quit);
+
+ Assert.True(WaitForEndOfInput(controller));
+ controller.DrainDue();
+
+ Assert.True(quit.IsCancellationRequested);
+ Assert.Empty(submitted);
+ Assert.Contains("quitting", output.ToString(), StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void StatusPrintsTheProvidedStatusTextAndNeverReachesSubmit()
+ {
+ using var input = new System.IO.StringReader("/status" + Environment.NewLine);
+ var submitted = new List();
+ var output = new StringWriter();
+ using var quit = new CancellationTokenSource();
+ using var controller = new HeadlessConsoleController(
+ input,
+ output,
+ line =>
+ {
+ submitted.Add(line);
+ return SubmitOutcome.Sent;
+ },
+ () => "generation=1 position=unknown plugins=0 loaded",
+ quit);
+
+ Assert.True(WaitForEndOfInput(controller));
+ controller.DrainDue();
+
+ Assert.Empty(submitted);
+ Assert.Contains(
+ "generation=1 position=unknown plugins=0 loaded",
+ output.ToString());
+ }
+
+ // ── HeadlessConsoleChatFormatter: channel-prefixed rendering ─────────
+
+ [Theory]
+ [InlineData("Bob", 0x50000010u, "hi", "[Local] Bob: hi")]
+ [InlineData("", 0u, "hi", "[Local] You: hi")]
+ public void FormatsLocalSpeechWithTheLocalLabel(
+ string sender, uint senderGuid, string text, string expected)
+ {
+ var entry = new RuntimeChatEntry(
+ Revision: 1,
+ SenderGuid: senderGuid,
+ Kind: (int)ChatKind.LocalSpeech,
+ Sender: sender,
+ Text: text,
+ ChannelName: string.Empty);
+
+ Assert.Equal(expected, HeadlessConsoleChatFormatter.Format(entry));
+ }
+
+ [Fact]
+ public void FormatsChannelBroadcastWithItsFriendlyName()
+ {
+ var entry = new RuntimeChatEntry(
+ Revision: 1,
+ SenderGuid: 0x50000010u,
+ Kind: (int)ChatKind.Channel,
+ Sender: "Bob",
+ Text: "group up",
+ ChannelName: "Fellowship");
+
+ Assert.Equal(
+ "[Fellowship] Bob: group up",
+ HeadlessConsoleChatFormatter.Format(entry));
+ }
+
+ [Theory]
+ [InlineData(0x50000010u, "Bob", "hi", "[Tell] Bob: hi")]
+ [InlineData(0u, "Bob", "hi", "[Tell] You -> Bob: hi")]
+ public void FormatsTellWithDirection(
+ uint senderGuid, string sender, string text, string expected)
+ {
+ var entry = new RuntimeChatEntry(
+ Revision: 1,
+ SenderGuid: senderGuid,
+ Kind: (int)ChatKind.Tell,
+ Sender: sender,
+ Text: text,
+ ChannelName: string.Empty);
+
+ Assert.Equal(expected, HeadlessConsoleChatFormatter.Format(entry));
+ }
+
+ // ── HeadlessSessionHost.SubmitConsoleLine: the real dispatch pipeline ─
+
+ [Fact]
+ public void SlashSayProducesTheSameOutboundTalkActionTheGraphicalRouteSends()
+ {
+ var captured = new List();
+ var operations = new FixtureSessionOperations
+ {
+ GameActionCapture = body => captured.Add(body),
+ };
+ using var credential = new HeadlessCredentialSecret("fixture", "password");
+ using var host = new HeadlessSessionHost(
+ Descriptor(),
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ host.Start().Status);
+
+ SubmitOutcome outcome = host.SubmitConsoleLine("/say hello", _ => { });
+
+ Assert.Equal(SubmitOutcome.Sent, outcome);
+ byte[] body = Assert.Single(captured);
+ Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body));
+ Assert.Equal("hello", TalkText(body));
+ }
+
+ [Fact]
+ public void PlainTextProducesTheSameOutboundTalkActionAsSlashSay()
+ {
+ var captured = new List();
+ var operations = new FixtureSessionOperations
+ {
+ GameActionCapture = body => captured.Add(body),
+ };
+ using var credential = new HeadlessCredentialSecret("fixture", "password");
+ using var host = new HeadlessSessionHost(
+ Descriptor(),
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ host.Start().Status);
+
+ SubmitOutcome outcome = host.SubmitConsoleLine("hello", _ => { });
+
+ Assert.Equal(SubmitOutcome.Sent, outcome);
+ byte[] body = Assert.Single(captured);
+ Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body));
+ Assert.Equal("hello", TalkText(body));
+ }
+
+ [Fact]
+ public void PluginVerbReachesTheRegisteredPluginCommandWithoutTouchingTheWire()
+ {
+ var captured = new List();
+ var operations = new FixtureSessionOperations
+ {
+ GameActionCapture = body => captured.Add(body),
+ };
+ using var credential = new HeadlessCredentialSecret("fixture", "password");
+ using var host = new HeadlessSessionHost(
+ Descriptor(),
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ host.Start().Status);
+ var received = new List();
+ using IDisposable registration = host.PluginCommands.Register(
+ "vt",
+ command => received.Add(command));
+
+ SubmitOutcome outcome = host.SubmitConsoleLine("/vt start", _ => { });
+
+ Assert.Equal(SubmitOutcome.ClientHandled, outcome);
+ PluginCommand command = Assert.Single(received);
+ Assert.Equal("vt", command.Verb);
+ Assert.Equal("start", command.Arguments);
+ Assert.Empty(captured);
+ }
+
+ [Fact]
+ public void UnknownVerbProducesTheSameInterfaceTextTheChatBoxShows()
+ {
+ var operations = new FixtureSessionOperations();
+ using var credential = new HeadlessCredentialSecret("fixture", "password");
+ using var host = new HeadlessSessionHost(
+ Descriptor(),
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ 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);
+
+ Assert.Equal(SubmitOutcome.UnknownCommand, outcome);
+ string text = Assert.Single(interfaceText);
+ Assert.Contains("Unknown command:", text);
+ }
+
+ private static bool WaitForEndOfInput(HeadlessConsoleController controller) =>
+ controller.Reader.EndOfInput.Wait(TimeSpan.FromSeconds(5));
+
+ private static uint ActionOpcode(byte[] body) =>
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, sizeof(uint)));
+
+ private static string TalkText(byte[] body)
+ {
+ ushort length = BinaryPrimitives.ReadUInt16LittleEndian(
+ body.AsSpan(12, sizeof(ushort)));
+ return Encoding.ASCII.GetString(body, 14, length);
+ }
+
+ private static HeadlessSessionDescriptor Descriptor() => new()
+ {
+ Id = "console-bot",
+ Endpoint = new HeadlessEndpointDescriptor
+ {
+ Host = "127.0.0.1",
+ Port = 9000,
+ },
+ Account = "account",
+ Character = new HeadlessCharacterSelector
+ {
+ Name = "headless",
+ },
+ Policy = new HeadlessBotPolicyDescriptor
+ {
+ Id = "idle",
+ },
+ Credential = new HeadlessCredentialReference
+ {
+ Provider = HeadlessCredentialProviderKind.Environment,
+ Reference = "CONSOLE_BOT_PASSWORD",
+ },
+ };
+
+ private sealed class FixtureSessionOperations : ILiveSessionOperations
+ {
+ public Action? GameActionCapture { get; init; }
+
+ public CharacterList.Parsed? Characters { get; init; } = new(
+ 0u,
+ [
+ new CharacterList.Character(0x50000001u, "Other", 0u),
+ new CharacterList.Character(0x50000002u, "Headless", 0u),
+ ],
+ [],
+ 11,
+ "account",
+ true,
+ true);
+
+ public IPEndPoint ResolveEndpoint(string host, int port) =>
+ new(IPAddress.Loopback, port);
+
+ public WorldSession CreateSession(IPEndPoint endpoint)
+ {
+ var session = new WorldSession(endpoint);
+ session.GameActionCapture = GameActionCapture;
+ return session;
+ }
+
+ public void Connect(WorldSession session, string user, string password)
+ {
+ }
+
+ public CharacterList.Parsed? GetCharacters(WorldSession session) =>
+ Characters;
+
+ public void EnterWorld(WorldSession session, int activeCharacterIndex)
+ {
+ }
+
+ public void Tick(WorldSession session)
+ {
+ }
+
+ public void DisposeSession(WorldSession session) => session.Dispose();
+ }
+}